The front page is Home: where you're needed comes first, however long ago it started, with what's at stake and how long it has waited; then what happened since you were last here, in words, with the agent work that settled, what landed, what deployed, what was decided and what was spent, switchable to the last 24 hours or 7 days; then what's running now. Your last visit is kept with your account, so it's the same on every device; Today's old address leads here. The home guide says how.
39 files+3322−19950/39 viewed
| 4 | 4 | are members of the same workspace: they talk in channels and direct | |
| 5 | 5 | messages and sit on the same teams. You hand an agent work, it does it, and | |
| 6 | 6 | it comes back with something you can act on: a pull request to merge, a | |
| 7 | − | document, a reply to approve. Today says what happened and what is waiting | |
| 8 | − | on you. Code and Deployments are built in, because what agents make has to | |
| 7 | + | document, a reply to approve. Home says where you are needed and what | |
| 8 | + | happened while you were away. Code and Deployments are built in, because what agents make has to | |
| 9 | 9 | be reviewed, tested and shipped. Everything specific to your business will | |
| 10 | 10 | come from extensions and integrations. | |
| 11 | 11 | ||
| ⋯ | |||
| 32 | 32 | ||
| 33 | 33 | | Feature | Status | | |
| 34 | 34 | | --- | --- | | |
| 35 | − | | The dock: Today, Chat, Notifications, Agents, Code, Artifacts and your pinned apps, with People, Workspace and your account at its foot; a bottom bar on phones | Live | | |
| 36 | − | | Today: what agents finished, how much was accepted the first time, what's waiting on you, what was spent, and where to start | Preview | | |
| 35 | + | | The dock: Home, Chat, Notifications, Agents, Code, Artifacts and your pinned apps, with People, Workspace and your account at its foot; a bottom bar on phones | Live | | |
| 36 | + | | Home: where you're needed now, what happened since you were last here (agent work and how much was accepted the first time, what landed, deploys, decisions, spend), what is running, and where to start | Preview | | |
| 37 | 37 | | Chat: channels, direct messages and threads, live, with people and agents as members | Live | | |
| 38 | 38 | | Notifications: mentions, reviews, approvals and alerts, with browser push | Live | | |
| 39 | 39 | | People and teams: members, roles, nested teams, invites | Live | | |
| 13 | 13 | redirects: { | |
| 14 | 14 | '/guides/g1t-agents/': '/guides/working-with-g1t/', | |
| 15 | 15 | '/guides/inbox/': '/guides/notifications/', | |
| 16 | + | '/guides/today/': '/guides/home/', | |
| 16 | 17 | }, | |
| 17 | 18 | integrations: [ | |
| 18 | 19 | starlight({ | |
| ⋯ | |||
| 78 | 79 | items: [ | |
| 79 | 80 | { label: 'Quickstart', slug: 'quickstart' }, | |
| 80 | 81 | { label: 'How g1t works', slug: 'concepts/overview' }, | |
| 81 | − | { label: 'Today', slug: 'guides/today' }, | |
| 82 | + | { label: 'Home', slug: 'guides/home' }, | |
| 82 | 83 | { label: 'Notifications', slug: 'guides/notifications' }, | |
| 83 | 84 | { label: 'Search and Explore', slug: 'guides/search' }, | |
| 84 | 85 | { label: 'Status and incidents', slug: 'guides/status' }, | |
| 182 | 182 | ||
| 183 | 183 | | | | | |
| 184 | 184 | | --- | --- | | |
| 185 | − | | **Sees** | Chat, Artifacts, Agents and Notifications. The dock has no Code, and Today shows what needs them in chat, from agents and in their notifications, without reviews. | | |
| 185 | + | | **Sees** | Chat, Artifacts, Agents and Notifications. The dock has no Code, and Home shows what needs them in chat, from agents and in their notifications, without reviews. | | |
| 186 | 186 | | **Can't open** | Any repository, issue, pull request, check or deploy page. The workspace's base permission and team grants don't apply to them. | | |
| 187 | 187 | | **Still sees work reach them** | Cards about issues, pull requests and deploys appear in their channels as summaries: title, state and who is on it. Opening one asks for Code access. | | |
| 188 | 188 | | **Can ask agents** | Anything about the product. Agents explain how things work and what changed, but never show them source code. Owners can tighten this so agents answer only from the workspace's docs. | |
| 1 | + | --- | |
| 2 | + | title: Home | |
| 3 | + | description: Your workspace's front page. Where you're needed now, what happened since you were last here, what is running, and the one item to start with. How every number is worked out. | |
| 4 | + | --- | |
| 5 | + | ||
| 6 | + | **Home** is the first page of your workspace, at `/<workspace>/-/home`. | |
| 7 | + | It answers three questions, in this order: | |
| 8 | + | ||
| 9 | + | 1. **Where you're needed**: everything waiting on you, however long ago | |
| 10 | + | it started, with the action beside each item. | |
| 11 | + | 2. **What happened while you were away**: the agents' work and how much | |
| 12 | + | of it was accepted the first time, what landed, deploys, decisions and | |
| 13 | + | spend, since you were last here. | |
| 14 | + | 3. **What is running now**: agent sessions, agents' changes and builds | |
| 15 | + | still going. | |
| 16 | + | ||
| 17 | + | **Start here** picks the one item to do first, and **Ask g1t** at the top | |
| 18 | + | sends a message to g1t from the page. | |
| 19 | + | ||
| 20 | + | Every number on the page comes from what g1t has recorded. Nothing is | |
| 21 | + | estimated. When a part of g1t does not answer, the section it feeds says | |
| 22 | + | so, and the rest of the page still shows. | |
| 23 | + | ||
| 24 | + | ## Where you're needed | |
| 25 | + | ||
| 26 | + | This list has no time window: something that started waiting a week ago | |
| 27 | + | is here until you deal with it. Each row says what it is, what is at | |
| 28 | + | stake, how long it has waited, and has its action beside it. | |
| 29 | + | ||
| 30 | + | | Source | Rows | | |
| 31 | + | | --- | --- | | |
| 32 | + | | Code | A failed production build; an agent's pull request that stopped and needs a decision, including one whose checks fail; a pull request ready for you to merge; a review asked of you by name; an invitation to a repository; an agent run that stopped reporting; a job waiting for a self-hosted runner | | |
| 33 | + | | Agents | The workspace's agents at 100% of its monthly agent budget, and an agent out of its own monthly budget, both shown only to those who may raise them; a session stopped at its spend cap that you may approve more for; an agent waiting | | |
| 34 | + | | Marketplace | A member's open request to add an extension or integration, shown only to owners | | |
| 35 | + | | Notifications | An unread notification in this workspace that is a warning (someone or something is waiting) or a failure | | |
| 36 | + | | Chat | A conversation that mentions you, or a direct message you haven't read. Muted conversations are left out | | |
| 37 | + | ||
| 38 | + | A notification about something already on the list, such as a pull | |
| 39 | + | request whose row is there, is left out. | |
| 40 | + | ||
| 41 | + | Rows are ordered most pressing first, by tier, then by how long each has | |
| 42 | + | waited, longest first: | |
| 43 | + | ||
| 44 | + | 1. A failed production build, or the workspace's agents at their budget. | |
| 45 | + | 2. Agent work that has stopped and can't go on without you: a session at | |
| 46 | + | its spend cap, an agent out of budget, a pull request g1t stopped on. | |
| 47 | + | 3. Finished work that only waits for you: a pull request to merge, a | |
| 48 | + | review, an invitation, an agent waiting, a request to add something. | |
| 49 | + | 4. Something that may be stuck: a quiet agent run, a job without a runner. | |
| 50 | + | 5. Unread warnings and failures in your notifications. | |
| 51 | + | 6. Mentions and direct messages. | |
| 52 | + | ||
| 53 | + | The list shows the first eight rows; **Show more** opens the rest. The | |
| 54 | + | sentence under the page's heading starts with how many things need you. | |
| 55 | + | ||
| 56 | + | ## Start here | |
| 57 | + | ||
| 58 | + | **Start here** is the first row of Where you're needed: the most pressing | |
| 59 | + | tier, and within it the row that has waited longest. g1t picks it with | |
| 60 | + | that rule, not with a model, so the same rows always give the same pick. | |
| 61 | + | The card shows who is on it, the next action, how long it has waited, what | |
| 62 | + | is at stake, and why it was picked. | |
| 63 | + | ||
| 64 | + | ## Since you were last here | |
| 65 | + | ||
| 66 | + | Home remembers when you were last on it, for each workspace, with your | |
| 67 | + | account, so it is the same on your laptop and your phone. The second half | |
| 68 | + | of the page covers what happened since then, and its heading says the span | |
| 69 | + | in words, such as **Since Tuesday evening · 2 days** or **Since 40 | |
| 70 | + | minutes ago**. | |
| 71 | + | ||
| 72 | + | | Switch | What it covers | | |
| 73 | + | | --- | --- | | |
| 74 | + | | **Since last visit** | From the end of your last visit to now. The default. | | |
| 75 | + | | **Last 24 hours** | The 24 hours up to now. | | |
| 76 | + | | **Last 7 days** | The 7 days up to now. | | |
| 77 | + | ||
| 78 | + | The switch is part of the address (`?window=24h`, `?window=7d`), so you | |
| 79 | + | can bookmark a span. | |
| 80 | + | ||
| 81 | + | How a visit is counted: | |
| 82 | + | ||
| 83 | + | - A visit counts once the page has been in view for five seconds. A quick | |
| 84 | + | refresh does not count, so it never wipes what you haven't looked at. | |
| 85 | + | - What it records is when the page loaded, so anything that happens while | |
| 86 | + | you are reading shows next time. | |
| 87 | + | - Visits less than 30 minutes apart are one visit. Going to a pull request | |
| 88 | + | and coming back, or refreshing, keeps the same span until you have been | |
| 89 | + | away for half an hour. | |
| 90 | + | - With no visit on record, Home shows the last 24 hours and says so. After | |
| 91 | + | more than 14 days away, it shows the last 14 days. | |
| 92 | + | ||
| 93 | + | The sentence under the heading sums it up, for example: | |
| 94 | + | ||
| 95 | + | > 8 things need you. Since Tuesday evening, agents finished 35 tasks and | |
| 96 | + | > 12 changes landed. 4 needed a fix after review. | |
| 97 | + | ||
| 98 | + | ### Tasks | |
| 99 | + | ||
| 100 | + | A **task** is one piece of agent work. Two kinds count: | |
| 101 | + | ||
| 102 | + | | Kind | What it is | Where it comes from | | |
| 103 | + | | --- | --- | --- | | |
| 104 | + | | Pull request | A pull request an agent made in one of the workspace's projects | Code | | |
| 105 | + | | Session | An agent session at the root of its tree: one a person started in a conversation, or one a routine started | Agents | | |
| 106 | + | ||
| 107 | + | A session that another session started, such as a subagent or a colleague | |
| 108 | + | brought in, is part of the task that started it and is not counted again. | |
| 109 | + | A conversation whose session filed an issue that an agent then worked on | |
| 110 | + | counts twice: once for the session, once for the pull request. | |
| 111 | + | ||
| 112 | + | A task counts in the span when it settled in it. Each has one outcome: | |
| 113 | + | ||
| 114 | + | | Outcome | Pull request | Session | | |
| 115 | + | | --- | --- | --- | | |
| 116 | + | | Accepted first time | Merged, and the agent was never sent back to revise it | Never: sessions are not reviewed | | |
| 117 | + | | Fixed after review | Merged after the agent was sent back to revise it at least once | Never | | |
| 118 | + | | Session finished | — | Finished | | |
| 119 | + | | Didn't finish | Closed without merging | Failed or stopped | | |
| 120 | + | ||
| 121 | + | Work that is still going is under [Running now](#running-now), not here. | |
| 122 | + | "Sent back to revise" means g1t started a revise run on the pull request: | |
| 123 | + | it does this when checks fail or a review asks for changes. Fixes a person | |
| 124 | + | pushes themselves are not counted as revisions. | |
| 125 | + | ||
| 126 | + | ### Accepted first time | |
| 127 | + | ||
| 128 | + | The large percentage is: | |
| 129 | + | ||
| 130 | + | > pull requests accepted first time ÷ agents' pull requests that merged or | |
| 131 | + | > closed in the span | |
| 132 | + | ||
| 133 | + | A pull request closed without merging counts against it. Sessions are not | |
| 134 | + | part of it. With no agent pull request settled in the span, it shows as a | |
| 135 | + | dash. | |
| 136 | + | ||
| 137 | + | Under it, the change from **the 7 days before** is the same share over the | |
| 138 | + | seven days before the span began, in whole percentage points. It is left | |
| 139 | + | out when nothing settled in those days, or when g1t could not read back far | |
| 140 | + | enough to count them all. | |
| 141 | + | ||
| 142 | + | Beside the percentage are the counts (tasks settled, accepted first time, | |
| 143 | + | fixed after review, didn't finish) and a strip with one mark for each task, | |
| 144 | + | coloured by its outcome, in the order they settled. Point at a mark to see | |
| 145 | + | what it is, and select it to open it. A session in a conversation you are | |
| 146 | + | not in shows as "A session in a conversation you're not in". | |
| 147 | + | ||
| 148 | + | ### Landed | |
| 149 | + | ||
| 150 | + | Every pull request that merged in the span, a person's or an agent's, | |
| 151 | + | newest first, with who wrote it and who merged it when that was someone | |
| 152 | + | else. The card's line counts them and how many were agents'. | |
| 153 | + | ||
| 154 | + | ### Deploys | |
| 155 | + | ||
| 156 | + | Every build that finished in the span, production and previews: **Went | |
| 157 | + | live** when it built and was served (it may have been replaced since), or | |
| 158 | + | **Failed**. Skipped builds are left out. A workspace whose projects did not | |
| 159 | + | build in the span has no Deploys card. | |
| 160 | + | ||
| 161 | + | ### Decisions | |
| 162 | + | ||
| 163 | + | Decisions recorded in the workspace's [memory](/guides/agent-memory/) in | |
| 164 | + | the span, and Marketplace requests an owner answered, with who made each. | |
| 165 | + | Memories still waiting to be kept are left out. With none, the card is not | |
| 166 | + | shown. | |
| 167 | + | ||
| 168 | + | ### Spent | |
| 169 | + | ||
| 170 | + | What the workspace spent over the span, from its | |
| 171 | + | [statement](/guides/usage-and-billing/), with each line at its price: what | |
| 172 | + | was charged plus what your plan's included usage, a trial, a pool or a | |
| 173 | + | discount paid of it. The lines are the statement's kinds of charge, such | |
| 174 | + | as **Agent runs**, **Sandbox time**, **Self-hosted runner time** and | |
| 175 | + | **Deployments**, most first. Payments, credits, refunds, tax and card fees | |
| 176 | + | are money in, not spend, and are left out. Under the total are the days | |
| 177 | + | counted and the month so far, at price. | |
| 178 | + | ||
| 179 | + | The statement keeps whole days, midnight to midnight UTC, so spend counts | |
| 180 | + | from the start of the UTC day the span began: a span since 3 p.m. UTC | |
| 181 | + | yesterday counts all of yesterday. Usage that g1t measures through the | |
| 182 | + | month, such as hosting requests and storage, shows on the day it is added | |
| 183 | + | to the statement. | |
| 184 | + | ||
| 185 | + | **Where the work came from** counts the span's tasks by source: Chat | |
| 186 | + | (sessions started in a conversation), Code (pull requests), Schedules | |
| 187 | + | (sessions a routine started) and Another agent. | |
| 188 | + | ||
| 189 | + | ## Running now | |
| 190 | + | ||
| 191 | + | What is going right now, longest-going first: | |
| 192 | + | ||
| 193 | + | | Kind | What it is | | |
| 194 | + | | --- | --- | | |
| 195 | + | | Session | An agent session at the root of its tree that is queued, working, or waiting on the helpers it started. One stopped at its spend cap waits on a person, so it is under Where you're needed instead. | | |
| 196 | + | | Change | An agent's pull request that is in the agent's hands: being worked on, waiting on its checks, or a draft still being made | | |
| 197 | + | | Build | A project's newest build, queued or building | | |
| 198 | + | ||
| 199 | + | ## Without Code access | |
| 200 | + | ||
| 201 | + | A member whose membership does not include Code sees Home without anything | |
| 202 | + | from Code: | |
| 203 | + | ||
| 204 | + | - Tasks are sessions only. The large number is **Finished**, and the | |
| 205 | + | counts are sessions finished and didn't finish. Accepted first time is | |
| 206 | + | not shown, since it is measured on pull requests. | |
| 207 | + | - Where you're needed has no rows from Code, and no notifications about a | |
| 208 | + | repository. | |
| 209 | + | - Landed, Deploys and memory decisions are not shown, and Running now has | |
| 210 | + | sessions only. | |
| 211 | + | - Spent is the same for every member. | |
| 212 | + | ||
| 213 | + | ## When something doesn't answer | |
| 214 | + | ||
| 215 | + | Each section reads its own part of g1t and shows as soon as it has it. | |
| 216 | + | When one does not answer: | |
| 217 | + | ||
| 218 | + | - Where you're needed and Running now name the parts that didn't answer, | |
| 219 | + | so an empty list never reads as nothing waiting. | |
| 220 | + | - The agents' work counts what could be read and says which part is | |
| 221 | + | missing. If neither answered, it says so instead of showing counts. | |
| 222 | + | - Landed, Deploys and Spent each say their part didn't answer. | |
| 223 | + | - If your last visit can't be read, the span is the last 24 hours and the | |
| 224 | + | page says why. | |
| 225 | + | ||
| 226 | + | When a list g1t reads is longer than it reads at once (200 sessions, 200 | |
| 227 | + | revise runs, 100 pull requests per project and state, 50 projects, builds | |
| 228 | + | of 20 projects), the page says that some work may be missing. |
| 241 | 241 | **Mark all read** marks everything under the tab you are on as read. | |
| 242 | 242 | **Open Notifications**, at the foot of the panel, goes to the page. | |
| 243 | 243 | ||
| 244 | − | [Today](/guides/today/) shows the newest unread threads waiting on you, with | |
| 245 | − | what else needs you across the workspace. | |
| 244 | + | [Home](/guides/home/) shows the unread warnings and failures waiting on you, | |
| 245 | + | with what else needs you across the workspace. | |
| 246 | 246 | ||
| 247 | 247 | ## From the API and agents | |
| 248 | 248 |
| 1 | − | --- | |
| 2 | − | title: Today | |
| 3 | − | description: Your workspace's front page. How the agents' work went today, what is waiting on you with the action beside it, what the day cost, and the one item to start with. How every number is worked out. | |
| 4 | − | --- | |
| 5 | − | ||
| 6 | − | **Today** is the first page of your workspace, at `/<workspace>/-/today`. | |
| 7 | − | It sums up the day instead of opening a conversation: one sentence about | |
| 8 | − | the day, how the agents' work went, what is waiting on you, what the day | |
| 9 | − | cost, and the one thing g1t suggests you start with. Chat is one click | |
| 10 | − | away, and **Ask g1t** at the top sends a message to g1t from here. | |
| 11 | − | ||
| 12 | − | Every number on the page comes from what g1t has recorded. Nothing is | |
| 13 | − | estimated. When a part of g1t does not answer, the section it feeds says | |
| 14 | − | so, and the rest of the page still shows. | |
| 15 | − | ||
| 16 | − | ## What "today" means | |
| 17 | − | ||
| 18 | − | Today is your calendar day in your own time zone, which your browser | |
| 19 | − | tells g1t. Until it has, g1t uses UTC. | |
| 20 | − | ||
| 21 | − | **Spent today** is the one exception: the statement keeps days from | |
| 22 | − | midnight to midnight UTC, so the spend shown is for the current UTC day. | |
| 23 | − | See [Spent today](#spent-today). | |
| 24 | − | ||
| 25 | − | ## The sentence at the top | |
| 26 | − | ||
| 27 | − | Under the heading, one sentence counts the day's tasks and what waits on | |
| 28 | − | you, for example: | |
| 29 | − | ||
| 30 | − | > Oct 9 · Agents finished 35 tasks. 4 needed a fix after review, and 6 are | |
| 31 | − | > still open. 8 things are waiting on you. | |
| 32 | − | ||
| 33 | − | - **Finished** counts tasks accepted first time, fixed after review, and | |
| 34 | − | sessions that finished. | |
| 35 | − | - **Needed a fix after review**, **didn't finish** and **still open** are | |
| 36 | − | the counts defined under [Tasks](#tasks). | |
| 37 | − | - **Things waiting on you** is the number of rows in | |
| 38 | − | [Needs attention](#needs-attention). | |
| 39 | − | ||
| 40 | − | The **Review** button beside it shows the same number and opens your | |
| 41 | − | notifications. | |
| 42 | − | ||
| 43 | − | ## Tasks | |
| 44 | − | ||
| 45 | − | A **task** is one piece of agent work. Two kinds count: | |
| 46 | − | ||
| 47 | − | | Kind | What it is | Where it comes from | | |
| 48 | − | | --- | --- | --- | | |
| 49 | − | | Pull request | A pull request an agent made in one of the workspace's projects | Code | | |
| 50 | − | | Session | An agent session at the root of its tree: one a person started in a conversation, or one a routine started | Agents | | |
| 51 | − | ||
| 52 | − | A session that another session started, such as a subagent or a colleague | |
| 53 | − | brought in, is part of the task that started it and is not counted again. | |
| 54 | − | A conversation whose session filed an issue that an agent then worked on | |
| 55 | − | counts twice: once for the session, once for the pull request. | |
| 56 | − | ||
| 57 | − | A task is one of **today's** when it settled today, or when it is still | |
| 58 | − | open and something happened on it today. Each one has one outcome: | |
| 59 | − | ||
| 60 | − | | Outcome | Pull request | Session | | |
| 61 | − | | --- | --- | --- | | |
| 62 | − | | Accepted first time | Merged today, and the agent was never sent back to revise it | Never: sessions are not reviewed | | |
| 63 | − | | Fixed after review | Merged today after the agent was sent back to revise it at least once | Never | | |
| 64 | − | | Session finished | — | Finished today | | |
| 65 | − | | Still open | Draft or open, and updated today | Queued, working, waiting or waiting for spend approval, and updated today | | |
| 66 | − | | Didn't finish | Closed today without merging | Failed or stopped today | | |
| 67 | − | ||
| 68 | − | "Sent back to revise" means g1t started a revise run on the pull request: | |
| 69 | − | it does this when checks fail or a review asks for changes. Fixes a person | |
| 70 | − | pushes themselves are not counted as revisions. | |
| 71 | − | ||
| 72 | − | ## Accepted first time | |
| 73 | − | ||
| 74 | − | The large percentage is: | |
| 75 | − | ||
| 76 | − | > pull requests accepted first time ÷ agents' pull requests that merged or | |
| 77 | − | > closed today | |
| 78 | − | ||
| 79 | − | A pull request closed without merging counts against it. Open pull | |
| 80 | − | requests and sessions are not part of it. With no agent pull request | |
| 81 | − | settled today, the percentage shows as a dash. | |
| 82 | − | ||
| 83 | − | Under it, the change from the **last 7 days** is the same share over the | |
| 84 | − | seven calendar days before today, in whole percentage points. It is left | |
| 85 | − | out when nothing settled in those days, or when g1t could not read back far | |
| 86 | − | enough to count them all. | |
| 87 | − | ||
| 88 | − | ## The strip | |
| 89 | − | ||
| 90 | − | Beside the percentage are four counts (tasks today, accepted first time, | |
| 91 | − | fixed after review, still open) and a strip with one mark for each of | |
| 92 | − | today's tasks, coloured by its outcome. Settled tasks come first, in the | |
| 93 | − | order they settled, then open ones. Point at a mark to see what it is, and | |
| 94 | − | select it to open it. A session in a conversation you are not in shows as | |
| 95 | − | "A session in a conversation you're not in". | |
| 96 | − | ||
| 97 | − | ## Needs attention | |
| 98 | − | ||
| 99 | − | Each row is one thing waiting on you, with what is at stake and the | |
| 100 | − | action beside it. Rows come from: | |
| 101 | − | ||
| 102 | − | | Source | Rows | | |
| 103 | − | | --- | --- | | |
| 104 | − | | Code | A failed production build; an agent's pull request that stopped and needs a decision; a pull request ready for you to merge; a review asked of you by name; an invitation to a repository; an agent run that stopped reporting; a job waiting for a self-hosted runner | | |
| 105 | − | | Agents | A session stopped at its spend cap that you may approve more for; an agent out of its monthly budget, shown only to those who may raise it; an agent waiting | | |
| 106 | − | | Notifications | An unread notification in this workspace that is a warning (someone or something is waiting) or a failure | | |
| 107 | − | | Chat | A conversation that mentions you, or a direct message you haven't read. Muted conversations are left out | | |
| 108 | − | ||
| 109 | − | A notification about something already on the list, such as a pull request | |
| 110 | − | whose row is there, is left out. | |
| 111 | − | ||
| 112 | − | Rows are ordered most pressing first, by tier, then by how long each has | |
| 113 | − | waited, longest first: | |
| 114 | − | ||
| 115 | − | 1. A failed production build, or the workspace at its usage limit. | |
| 116 | − | 2. Agent work that has stopped and can't go on without you: a session at | |
| 117 | − | its spend cap, an agent out of budget, a pull request g1t stopped on. | |
| 118 | − | 3. Finished work that only waits for you: a pull request to merge, a | |
| 119 | − | review, an invitation, an agent waiting. | |
| 120 | − | 4. Something that may be stuck: a quiet agent run, a job without a runner. | |
| 121 | − | 5. Unread warnings and failures in your notifications. | |
| 122 | − | 6. Mentions and direct messages. | |
| 123 | − | ||
| 124 | − | The card shows the first six rows. **Where today's work came from** under | |
| 125 | − | it counts today's tasks by source: Chat (sessions started in a | |
| 126 | − | conversation), Code (pull requests), Schedules (sessions a routine | |
| 127 | − | started) and Another agent. | |
| 128 | − | ||
| 129 | − | ## Start here | |
| 130 | − | ||
| 131 | − | **Start here** is the first row of Needs attention: the most pressing | |
| 132 | − | tier, and within it the row that has waited longest. g1t picks it with that | |
| 133 | − | rule, not with a model, so the same rows always give the same pick. The | |
| 134 | − | card shows who is on it, the next action, how long it has waited, what is | |
| 135 | − | at stake, and why it was picked. | |
| 136 | − | ||
| 137 | − | ## Spent today | |
| 138 | − | ||
| 139 | − | **Spent today** is the current UTC day of your workspace's | |
| 140 | − | [statement](/guides/usage-and-billing/), with each line at its price: | |
| 141 | − | what was charged plus what your plan's included usage, a trial, a pool or a | |
| 142 | − | discount paid of it. The lines are the statement's kinds of charge, such as | |
| 143 | − | **Agent runs**, **Sandbox time**, **Self-hosted runner time** and | |
| 144 | − | **Deployments**, most first. Payments, credits, refunds, tax and card fees | |
| 145 | − | are money in, not spend, and are left out. Usage that g1t measures through | |
| 146 | − | the month, such as hosting requests and storage, shows on the day it is | |
| 147 | − | added to the statement. Under the total is the month so far, at price. | |
| 148 | − | ||
| 149 | − | ## Without Code access | |
| 150 | − | ||
| 151 | − | A member whose membership does not include Code sees Today without | |
| 152 | − | anything from Code: | |
| 153 | − | ||
| 154 | − | - Tasks are sessions only. The large number is **Finished today**, and the | |
| 155 | − | counts are sessions finished, didn't finish and still open. Accepted | |
| 156 | − | first time is not shown, since it is measured on pull requests. | |
| 157 | − | - Needs attention has no rows from Code, and no notifications about a | |
| 158 | − | repository. | |
| 159 | − | - Spent today is the same for every member. | |
| 160 | − | ||
| 161 | − | ## When something doesn't answer | |
| 162 | − | ||
| 163 | − | Each section reads its own part of g1t and shows as soon as it has it. | |
| 164 | − | When one does not answer: | |
| 165 | − | ||
| 166 | − | - The day's work counts what could be read and says which part is missing. | |
| 167 | − | If neither answered, it says so instead of showing counts. | |
| 168 | − | - Needs attention names the parts that didn't answer, so an empty list | |
| 169 | − | never reads as nothing waiting. | |
| 170 | − | - Spent today says billing didn't answer. | |
| 171 | − | ||
| 172 | − | When a list g1t reads is longer than it reads at once (200 sessions, 200 | |
| 173 | − | revise runs, 100 pull requests per project and state, 50 projects), the | |
| 174 | − | page says that some of today's tasks may be missing. |
| 537 | 537 | ||
| 538 | 538 | | Part | Where | What it holds | | |
| 539 | 539 | | --- | --- | --- | | |
| 540 | − | | **The dock** | A rounded bar down the left | g1t's mark, which goes to [Today](/guides/today/); the built-in apps; the apps you pinned; **Apps**; and at its foot **People**, **Workspace** and your account. | | |
| 541 | − | | **The sidebar** | Beside the dock | The workspace and its switcher at the top, then the lists of the app you are in. Today and Apps have none. | | |
| 540 | + | | **The dock** | A rounded bar down the left | g1t's mark, which goes to [Home](/guides/home/); the built-in apps; the apps you pinned; **Apps**; and at its foot **People**, **Workspace** and your account. | | |
| 541 | + | | **The sidebar** | Beside the dock | The workspace and its switcher at the top, then the lists of the app you are in. Home and Apps have none. | | |
| 542 | 542 | | **The page** | A rounded panel, the rest of the window | Its header: the button that shows or hides the sidebar, where you are, **Search or jump to** (<kbd>Ctrl</kbd> <kbd>K</kbd>, or <kbd>⌘</kbd> <kbd>K</kbd> on a Mac), [your spend this month](/guides/spend/#in-the-top-bar), **Ask g1t**, the [notifications](/guides/notifications/) bell and **Create new**. Then the page itself. | | |
| 543 | 543 | ||
| 544 | 544 | ### The dock | |
| ⋯ | |||
| 548 | 548 | ||
| 549 | 549 | | App | Opens | | |
| 550 | 550 | | --- | --- | | |
| 551 | − | | **Today** | What needs you across the workspace. See [Today](/guides/today/). | | |
| 551 | + | | **Home** | Where you're needed across the workspace, and what happened while you were away. See [Home](/guides/home/). | | |
| 552 | 552 | | **Chat** | Channels and messages. See [Chat](/guides/chat/). | | |
| 553 | 553 | | **Notifications** | Reviews, mentions, failures and what agents wait on. See [notifications](/guides/notifications/). | | |
| 554 | 554 | | **Agents** | The workspace's agents and their sessions. See [agents](/guides/agents/). | | |
| ⋯ | |||
| 701 | 701 | ||
| 702 | 702 | | | What it does | | |
| 703 | 703 | | --- | --- | | |
| 704 | − | | **The bar along the bottom** | **Today**, **Chat**, **Notifications**, **Agents** and **Code** (for members with Code access), each with what is unread, and **More**. It steps aside while the keyboard is up and inside a conversation. | | |
| 704 | + | | **The bar along the bottom** | **Home**, **Chat**, **Notifications**, **Agents** and **Code** (for members with Code access), each with what is unread, and **More**. It steps aside while the keyboard is up and inside a conversation. | | |
| 705 | 705 | | **More** | A panel above the bar with **All apps**, the [Marketplace](/guides/marketplace/), your pinned apps, **Artifacts**, **People**, **Workspace**, and **You and help**: your status, profile and settings, [appearance](#appearance), the documentation, support, status, the keyboard's shortcuts and signing out. | | |
| 706 | 706 | | **The sidebar button**, at the left of the page's header | Opens the sidebar of the app you are in from the left: the same lists and links as on a computer. Tap outside it, or open a page, and it closes. | | |
| 707 | 707 | | **The tab you are already on** | Tap it again to open that app's sidebar too. | | |
| ⋯ | |||
| 734 | 734 | Mission control is Code's **Overview**, at `g1t.sh/<workspace>/-/overview`. | |
| 735 | 735 | It shows where you are needed in the workspace's code, what its agents are | |
| 736 | 736 | doing, and what landed without you. Signed in, `g1t.sh` itself opens | |
| 737 | − | [Today](/guides/today/) in the workspace you are in. | |
| 737 | + | [Home](/guides/home/) in the workspace you are in. | |
| 738 | 738 | ||
| 739 | 739 | Under the greeting, one line sums up the week, such as *Agents landed 37 | |
| 740 | 740 | of their 39 changes this week without you, and people landed 8 changes of | |
| 15 | 15 | agents are members of the same workspace: they talk in channels and direct | |
| 16 | 16 | messages, and they sit on the same teams. You hand an agent work, it does it | |
| 17 | 17 | on a runner, and it comes back with something you can act on: a pull request | |
| 18 | − | to merge, a document, a reply to approve. **Today** tells you what happened | |
| 19 | − | and what is waiting on you. **Code** and **Deployments** are built in, | |
| 18 | + | to merge, a document, a reply to approve. **Home** tells you where you are | |
| 19 | + | needed and what happened while you were away. **Code** and **Deployments** are built in, | |
| 20 | 20 | because what agents make has to be reviewed, tested and shipped somewhere. | |
| 21 | 21 | ||
| 22 | 22 | g1t is MIT-licensed. You can use it on g1t.sh, or run it yourself. | |
| ⋯ | |||
| 55 | 55 | ## The workspace | |
| 56 | 56 | ||
| 57 | 57 | Everything happens in a workspace: your company, or one team in it. The | |
| 58 | − | dock down the left holds Today, Chat, Notifications, Agents, Code and | |
| 58 | + | dock down the left holds Home, Chat, Notifications, Agents, Code and | |
| 59 | 59 | Artifacts, the apps you pin, and People, Workspace and your account at its | |
| 60 | 60 | foot. On a phone it is a bar along the bottom. | |
| 61 | 61 | ||
| 62 | 62 | | Feature | What it does | Status | | |
| 63 | 63 | | --- | --- | --- | | |
| 64 | − | | [Today](/guides/today/) | The front page: what agents finished today, how much was accepted the first time, what is waiting on you, what was spent, and where to start. | <Status is="preview" /> | | |
| 64 | + | | [Home](/guides/home/) | The front page: where you're needed now, what happened since you were last here, what is running, and where to start. | <Status is="preview" /> | | |
| 65 | 65 | | [Chat](/guides/chat/) | Channels, direct messages and threads, live. People and agents are members alike. No seats and no history limit. | <Status is="live" /> | | |
| 66 | 66 | | [Notifications](/guides/notifications/) | Everything waiting on you: mentions, reviews, approvals and alerts, with browser push. | <Status is="live" /> | | |
| 67 | 67 | | [People and teams](/guides/people-and-teams/) | Everyone, people and agents, in one search, with profiles, reporting lines, an org chart and teams that include agents. Members, roles, nested teams and invites. | <Status is="live" /> | | |
| 1 | − | import { Bell, Building2, Code2, LayoutGrid, Lock, MessagesSquare, Pin, Search, Shapes, Sparkles, Store, Sun, Users } from "lucide-react"; | |
| 1 | + | import { Bell, Building2, Code2, LayoutGrid, Lock, MessagesSquare, Pin, Search, Shapes, Sparkles, Store, House, Users } from "lucide-react"; | |
| 2 | 2 | import { type ReactNode, useEffect, useMemo, useState } from "react"; | |
| 3 | 3 | import { Link, useFetcher, useFetchers } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 12 | 12 | /** Each built-in app's icon, at the size asked for. */ | |
| 13 | 13 | export function appIcon(key: BuiltinApp, size = 18): ReactNode { | |
| 14 | 14 | const icons: Record<BuiltinApp, ReactNode> = { | |
| 15 | − | today: <Sun size={size} />, | |
| 15 | + | home: <House size={size} />, | |
| 16 | 16 | chat: <MessagesSquare size={size} />, | |
| 17 | 17 | notifications: <Bell size={size} />, | |
| 18 | 18 | agents: <Sparkles size={size} />, | |
| 13 | 13 | GitMerge, | |
| 14 | 14 | GitPullRequest, | |
| 15 | 15 | Hash, | |
| 16 | − | Sun, | |
| 16 | + | House, | |
| 17 | 17 | Bell, | |
| 18 | 18 | MessagesSquare, | |
| 19 | 19 | Paperclip, | |
| ⋯ | |||
| 114 | 114 | } | |
| 115 | 115 | ||
| 116 | 116 | const RAIL: { icon: ReactNode; label: string; on?: boolean }[] = [ | |
| 117 | − | { icon: <Sun size={16} />, label: "Today" }, | |
| 117 | + | { icon: <House size={16} />, label: "Home" }, | |
| 118 | 118 | { icon: <Code2 size={16} />, label: "Code" }, | |
| 119 | 119 | { icon: <MessagesSquare size={16} />, label: "Chat", on: true }, | |
| 120 | 120 | { icon: <BookOpen size={16} />, label: "Docs" }, | |
| 21 | 21 | import { type BuiltinApp, type PinnableApp } from "../lib/apps"; | |
| 22 | 22 | import { cn } from "../lib/cn"; | |
| 23 | 23 | import { paletteKeyLabel } from "../lib/shortcut"; | |
| 24 | − | import { type ModeKey, modeHome, modeOf, todayPath } from "../lib/workspace-nav"; | |
| 24 | + | import { type ModeKey, homePagePath, modeHome, modeOf } from "../lib/workspace-nav"; | |
| 25 | 25 | ||
| 26 | 26 | /** The dock's column: 8px from the window's edge, 80px wide. */ | |
| 27 | 27 | export const DOCK_COLUMN = "5.5rem"; | |
| ⋯ | |||
| 143 | 143 | <DropdownMenuLabel>Switch workspace</DropdownMenuLabel> | |
| 144 | 144 | {(user.workspaces ?? []).map((membership) => ( | |
| 145 | 145 | <DropdownMenuItem key={membership.slug} asChild> | |
| 146 | − | <Link to={todayPath(membership.slug)}> | |
| 146 | + | <Link to={homePagePath(membership.slug)}> | |
| 147 | 147 | <Avatar name={membership.slug} image={membership.avatar} size={24} square /> | |
| 148 | 148 | <span className="flex min-w-0 grow flex-col leading-tight"> | |
| 149 | 149 | <span className="truncate">{displayName(membership)}</span> | |
| ⋯ | |||
| 244 | 244 | ||
| 245 | 245 | /** | |
| 246 | 246 | * The dock down the left: g1t's mark, which | |
| 247 | − | * leads to Today; the built-in apps (Today, Chat, Notifications, Agents, | |
| 247 | + | * leads to Home; the built-in apps (Home, Chat, Notifications, Agents, | |
| 248 | 248 | * Code and Artifacts) with their names; the apps you pinned, as icons; | |
| 249 | 249 | * the Apps launcher; and at its foot People, Workspace and your account. | |
| 250 | 250 | * What is lit follows the address. A member without Code access has no | |
| ⋯ | |||
| 270 | 270 | const code = hasCodeAccess(workspace); | |
| 271 | 271 | const { pins, toggle } = useAppPins(slug, saved); | |
| 272 | 272 | const builtins: Item[] = [ | |
| 273 | − | { key: "today", label: "Today" }, | |
| 273 | + | { key: "home", label: "Home" }, | |
| 274 | 274 | { | |
| 275 | 275 | key: "chat", | |
| 276 | 276 | label: "Chat", | |
| ⋯ | |||
| 290 | 290 | className="flex h-full flex-col overflow-hidden rounded-2xl border border-line bg-dock shadow-[0_1px_0_rgba(255,255,255,0.03)_inset,0_8px_24px_rgba(0,0,0,0.18)]" | |
| 291 | 291 | > | |
| 292 | 292 | <div className="flex h-14 shrink-0 items-center justify-center"> | |
| 293 | − | <Hint label="Today" side="right"> | |
| 294 | − | <Link to={todayPath(slug)} aria-label="g1t: Today" className="flex size-9 items-center justify-center rounded-[11px] transition-colors hover:bg-raised"> | |
| 293 | + | <Hint label="Home" side="right"> | |
| 294 | + | <Link to={homePagePath(slug)} aria-label="g1t: Home" className="flex size-9 items-center justify-center rounded-[11px] transition-colors hover:bg-raised"> | |
| 295 | 295 | <Mark tight className="h-[1.125rem] w-auto" /> | |
| 296 | 296 | </Link> | |
| 297 | 297 | </Hint> | |
| 1 | + | /** | |
| 2 | + | * Home's parts (routes/workspace/home.tsx): Where you're needed, Start | |
| 3 | + | * here, what happened in the span (the agents' work, what landed, builds, | |
| 4 | + | * decisions, spend) and Running now. Each takes what lib/home.ts worked | |
| 5 | + | * out and only draws it. | |
| 6 | + | */ | |
| 7 | + | import { | |
| 8 | + | AlertTriangle, | |
| 9 | + | ArrowRight, | |
| 10 | + | AtSign, | |
| 11 | + | Bell, | |
| 12 | + | Bot, | |
| 13 | + | CircleDollarSign, | |
| 14 | + | GitMerge, | |
| 15 | + | GitPullRequest, | |
| 16 | + | Hourglass, | |
| 17 | + | Lightbulb, | |
| 18 | + | Loader, | |
| 19 | + | Mail, | |
| 20 | + | Rocket, | |
| 21 | + | Server, | |
| 22 | + | Sparkles, | |
| 23 | + | Store, | |
| 24 | + | User, | |
| 25 | + | } from "lucide-react"; | |
| 26 | + | import type { ReactNode } from "react"; | |
| 27 | + | import { Link } from "react-router"; | |
| 28 | + | ||
| 29 | + | import { | |
| 30 | + | type Attention, | |
| 31 | + | type AttentionKind, | |
| 32 | + | type AttentionRow, | |
| 33 | + | type DecisionRow, | |
| 34 | + | type DeployRow, | |
| 35 | + | type LandedChange, | |
| 36 | + | type RunningRow, | |
| 37 | + | type Span, | |
| 38 | + | type SpanWork, | |
| 39 | + | type Spend, | |
| 40 | + | type Stake, | |
| 41 | + | type TaskOutcome, | |
| 42 | + | OUTCOME_LABEL, | |
| 43 | + | SOURCE_LABEL, | |
| 44 | + | WINDOWS, | |
| 45 | + | dollars, | |
| 46 | + | trendLabel, | |
| 47 | + | waited, | |
| 48 | + | whenShort, | |
| 49 | + | } from "../lib/home"; | |
| 50 | + | import { cn } from "../lib/cn"; | |
| 51 | + | import { Skeleton } from "./ui/skeleton"; | |
| 52 | + | import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; | |
| 53 | + | ||
| 54 | + | export function Card({ | |
| 55 | + | title, | |
| 56 | + | hint, | |
| 57 | + | link, | |
| 58 | + | children, | |
| 59 | + | className = "", | |
| 60 | + | }: { | |
| 61 | + | title: ReactNode; | |
| 62 | + | /** A few words under the title. */ | |
| 63 | + | hint?: string; | |
| 64 | + | link?: { label: string; to: string } | null; | |
| 65 | + | children: ReactNode; | |
| 66 | + | className?: string; | |
| 67 | + | }) { | |
| 68 | + | return ( | |
| 69 | + | <section className={`rounded-2xl border border-line bg-surface ${className}`}> | |
| 70 | + | <header className="flex items-start gap-2 px-5 pt-4 pb-3"> | |
| 71 | + | <div className="min-w-0"> | |
| 72 | + | <h2 className="text-sm font-semibold text-fg">{title}</h2> | |
| 73 | + | {hint && <p className="mt-0.5 text-xs text-muted">{hint}</p>} | |
| 74 | + | </div> | |
| 75 | + | {link && ( | |
| 76 | + | <Link to={link.to} className="ml-auto inline-flex shrink-0 items-center gap-1 pt-0.5 text-xs text-muted hover:text-fg"> | |
| 77 | + | {link.label} | |
| 78 | + | <ArrowRight size={12} /> | |
| 79 | + | </Link> | |
| 80 | + | )} | |
| 81 | + | </header> | |
| 82 | + | {children} | |
| 83 | + | </section> | |
| 84 | + | ); | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | export function Quiet({ children }: { children: ReactNode }) { | |
| 88 | + | return <p className="px-5 pb-5 text-sm text-muted">{children}</p>; | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | const listOf = (names: string[]) => (names.length <= 1 ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`); | |
| 92 | + | ||
| 93 | + | function Missing({ names, what }: { names: string[]; what: string }) { | |
| 94 | + | if (names.length === 0) return null; | |
| 95 | + | return ( | |
| 96 | + | <p className="flex items-center gap-1.5 border-t border-line px-5 py-2.5 text-xs text-faint"> | |
| 97 | + | <AlertTriangle size={12} className="shrink-0" /> | |
| 98 | + | {listOf(names)} didn't answer, so {what} isn't listed. | |
| 99 | + | </p> | |
| 100 | + | ); | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // --- The window switch ---------------------------------------------------------- | |
| 104 | + | ||
| 105 | + | /** Since last visit, last 24 hours, last 7 days: links, so the span is in the address and works without script. */ | |
| 106 | + | export function WindowSwitch({ span }: { span: Pick<Span, "key"> }) { | |
| 107 | + | return ( | |
| 108 | + | <nav aria-label="What happened over" className="flex shrink-0 self-start rounded-lg border border-line bg-surface p-0.5"> | |
| 109 | + | {WINDOWS.map((option) => { | |
| 110 | + | const current = option.key === span.key; | |
| 111 | + | return ( | |
| 112 | + | <Link | |
| 113 | + | key={option.key} | |
| 114 | + | to={option.key === "last" ? "?" : `?window=${option.key}`} | |
| 115 | + | preventScrollReset | |
| 116 | + | aria-current={current ? "true" : undefined} | |
| 117 | + | className={cn( | |
| 118 | + | "rounded-md px-2.5 py-1 text-xs font-medium whitespace-nowrap transition-colors", | |
| 119 | + | current ? "bg-raised text-fg shadow-[0_0_0_1px_var(--color-line-strong)]" : "text-muted hover:text-fg", | |
| 120 | + | )} | |
| 121 | + | > | |
| 122 | + | {option.label} | |
| 123 | + | </Link> | |
| 124 | + | ); | |
| 125 | + | })} | |
| 126 | + | </nav> | |
| 127 | + | ); | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // --- Where you're needed --------------------------------------------------------- | |
| 131 | + | ||
| 132 | + | const ICON: Record<AttentionKind, ReactNode> = { | |
| 133 | + | limit: <CircleDollarSign size={16} className="text-danger" />, | |
| 134 | + | deploy: <Rocket size={16} className="text-danger" />, | |
| 135 | + | session_cap: <CircleDollarSign size={16} className="text-warn" />, | |
| 136 | + | agent_budget: <CircleDollarSign size={16} className="text-danger" />, | |
| 137 | + | pull_blocked: <GitPullRequest size={16} className="text-warn" />, | |
| 138 | + | ready: <GitMerge size={16} className="text-accent" />, | |
| 139 | + | review: <GitPullRequest size={16} className="text-info" />, | |
| 140 | + | invitation: <Mail size={16} className="text-info" />, | |
| 141 | + | install_request: <Store size={16} className="text-info" />, | |
| 142 | + | agent_waiting: <Sparkles size={16} className="text-warn" />, | |
| 143 | + | stuck: <Hourglass size={16} className="text-warn" />, | |
| 144 | + | runner: <Server size={16} className="text-warn" />, | |
| 145 | + | notification: <Bell size={16} className="text-warn" />, | |
| 146 | + | chat: <AtSign size={16} className="text-accent" />, | |
| 147 | + | }; | |
| 148 | + | ||
| 149 | + | const STAKE_TONE: Record<NonNullable<Stake["tone"]> | "none", string> = { | |
| 150 | + | danger: "text-danger", | |
| 151 | + | warn: "text-warn", | |
| 152 | + | accent: "text-accent", | |
| 153 | + | none: "text-fg-soft", | |
| 154 | + | }; | |
| 155 | + | ||
| 156 | + | function StakeText({ stake }: { stake: Stake }) { | |
| 157 | + | return <span className={`text-xs font-medium tabular-nums ${STAKE_TONE[stake.tone ?? "none"]}`}>{stake.text}</span>; | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | /** Rows shown before the rest fold away. */ | |
| 161 | + | const ROWS = 8; | |
| 162 | + | ||
| 163 | + | function NeedItem({ row, now }: { row: AttentionRow; now: number }) { | |
| 164 | + | return ( | |
| 165 | + | <li className="flex items-start gap-3 border-t border-line px-5 py-3 first:border-t-0"> | |
| 166 | + | <span className="mt-0.5 shrink-0">{ICON[row.kind]}</span> | |
| 167 | + | <div className="min-w-0 grow"> | |
| 168 | + | <Link to={row.action.to} className="line-clamp-2 text-sm font-semibold text-fg hover:underline sm:block sm:truncate"> | |
| 169 | + | {row.title} | |
| 170 | + | </Link> | |
| 171 | + | <p className="truncate text-xs text-muted">{row.detail}</p> | |
| 172 | + | <div className="mt-1 flex flex-wrap items-center gap-x-2 text-xs sm:hidden"> | |
| 173 | + | {row.stake && <StakeText stake={row.stake} />} | |
| 174 | + | {row.at > 0 && ( | |
| 175 | + | <span className="text-faint" suppressHydrationWarning> | |
| 176 | + | waiting {waited(row.at, now)} | |
| 177 | + | </span> | |
| 178 | + | )} | |
| 179 | + | </div> | |
| 180 | + | </div> | |
| 181 | + | <div className="hidden max-w-48 shrink-0 pt-0.5 text-right whitespace-nowrap sm:block"> | |
| 182 | + | {row.stake && <StakeText stake={row.stake} />} | |
| 183 | + | {row.at > 0 && ( | |
| 184 | + | <div className="text-[0.6875rem] text-faint tabular-nums" suppressHydrationWarning> | |
| 185 | + | waiting {waited(row.at, now)} | |
| 186 | + | </div> | |
| 187 | + | )} | |
| 188 | + | </div> | |
| 189 | + | <Link | |
| 190 | + | to={row.action.to} | |
| 191 | + | className="inline-flex h-7 shrink-0 items-center rounded-md border border-line-strong px-2.5 text-xs font-medium whitespace-nowrap text-fg transition-colors hover:border-accent/50 hover:bg-raised" | |
| 192 | + | > | |
| 193 | + | {row.action.label} | |
| 194 | + | </Link> | |
| 195 | + | </li> | |
| 196 | + | ); | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | /** Everything waiting on you, most pressing and longest waiting first; past the first few, the rest fold away. */ | |
| 200 | + | export function NeedsList({ attention, now }: { attention: Attention; now: number }) { | |
| 201 | + | const { rows, missing } = attention; | |
| 202 | + | return ( | |
| 203 | + | <> | |
| 204 | + | {rows.length === 0 ? ( | |
| 205 | + | <Quiet>{missing.length > 0 ? "Nothing needs you in what could be read." : "Nothing needs you. Reviews, approvals and messages for you show here as soon as they arrive."}</Quiet> | |
| 206 | + | ) : ( | |
| 207 | + | <ul> | |
| 208 | + | {rows.slice(0, ROWS).map((row) => ( | |
| 209 | + | <NeedItem key={row.key} row={row} now={now} /> | |
| 210 | + | ))} | |
| 211 | + | </ul> | |
| 212 | + | )} | |
| 213 | + | {rows.length > ROWS && ( | |
| 214 | + | <details className="group border-t border-line"> | |
| 215 | + | <summary className="cursor-pointer list-none px-5 py-2.5 text-xs font-medium text-fg-soft hover:text-fg"> | |
| 216 | + | <span className="group-open:hidden">Show {(rows.length - ROWS).toLocaleString("en-US")} more</span> | |
| 217 | + | <span className="hidden group-open:inline">Show fewer</span> | |
| 218 | + | </summary> | |
| 219 | + | <ul className="border-t border-line"> | |
| 220 | + | {rows.slice(ROWS).map((row) => ( | |
| 221 | + | <NeedItem key={row.key} row={row} now={now} /> | |
| 222 | + | ))} | |
| 223 | + | </ul> | |
| 224 | + | </details> | |
| 225 | + | )} | |
| 226 | + | <Missing names={missing} what="what waits there" /> | |
| 227 | + | </> | |
| 228 | + | ); | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | export function RowsSkeleton({ rows = 3 }: { rows?: number }) { | |
| 232 | + | return ( | |
| 233 | + | <div className="space-y-4 px-5 pb-5" aria-busy="true"> | |
| 234 | + | {Array.from({ length: rows }, (_, i) => ( | |
| 235 | + | <div key={i} className="flex items-center gap-3"> | |
| 236 | + | <Skeleton className="size-4 rounded" /> | |
| 237 | + | <div className="grow space-y-1.5"> | |
| 238 | + | <Skeleton className="h-3.5" style={{ width: `${60 - i * 8}%` }} /> | |
| 239 | + | <Skeleton className="h-3" style={{ width: `${80 - i * 10}%` }} /> | |
| 240 | + | </div> | |
| 241 | + | <Skeleton className="h-7 w-16" /> | |
| 242 | + | </div> | |
| 243 | + | ))} | |
| 244 | + | </div> | |
| 245 | + | ); | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | // --- Start here ----------------------------------------------------------------- | |
| 249 | + | ||
| 250 | + | export function StartHere({ attention, now }: { attention: Attention; now: number }) { | |
| 251 | + | const start = attention.start; | |
| 252 | + | return ( | |
| 253 | + | <Card | |
| 254 | + | title={ | |
| 255 | + | <> | |
| 256 | + | Start here <span className="font-normal text-faint">· picked by g1t</span> | |
| 257 | + | </> | |
| 258 | + | } | |
| 259 | + | > | |
| 260 | + | {!start ? ( | |
| 261 | + | <Quiet>{attention.missing.length > 0 ? "Nothing to pick from what could be read." : "Nothing needs you, so there's nothing to pick."}</Quiet> | |
| 262 | + | ) : ( | |
| 263 | + | <div className="px-5 pb-5"> | |
| 264 | + | <div className="flex items-start gap-3"> | |
| 265 | + | <span className="mt-0.5 shrink-0">{ICON[start.row.kind]}</span> | |
| 266 | + | <div className="min-w-0"> | |
| 267 | + | <p className="text-[0.9375rem] leading-snug font-semibold text-fg">{start.row.title}</p> | |
| 268 | + | <p className="mt-1 line-clamp-2 text-xs text-muted">{start.row.detail}</p> | |
| 269 | + | </div> | |
| 270 | + | </div> | |
| 271 | + | <dl className="mt-4 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs"> | |
| 272 | + | {start.row.owner && ( | |
| 273 | + | <> | |
| 274 | + | <dt className="text-muted">Owner</dt> | |
| 275 | + | <dd className="flex min-w-0 items-center gap-1.5 text-fg-soft"> | |
| 276 | + | {start.row.owner.agent ? <Bot size={12} className="shrink-0 text-accent" /> : <User size={12} className="shrink-0 text-muted" />} | |
| 277 | + | <span className="truncate">{start.row.owner.agent ? `@${start.row.owner.name}` : start.row.owner.name}</span> | |
| 278 | + | </dd> | |
| 279 | + | </> | |
| 280 | + | )} | |
| 281 | + | <dt className="text-muted">Next</dt> | |
| 282 | + | <dd className="text-fg-soft">{start.row.action.label}</dd> | |
| 283 | + | {start.row.at > 0 && ( | |
| 284 | + | <> | |
| 285 | + | <dt className="text-muted">Waiting</dt> | |
| 286 | + | <dd className="text-fg-soft" suppressHydrationWarning> | |
| 287 | + | {waited(start.row.at, now)} | |
| 288 | + | </dd> | |
| 289 | + | </> | |
| 290 | + | )} | |
| 291 | + | {start.row.stake && ( | |
| 292 | + | <> | |
| 293 | + | <dt className="text-muted">At stake</dt> | |
| 294 | + | <dd> | |
| 295 | + | <StakeText stake={start.row.stake} /> | |
| 296 | + | </dd> | |
| 297 | + | </> | |
| 298 | + | )} | |
| 299 | + | </dl> | |
| 300 | + | <p className="mt-4 rounded-lg bg-raised/60 px-3 py-2.5 text-xs leading-relaxed text-fg-soft"> | |
| 301 | + | <span className="font-medium text-fg">Why this one: </span> | |
| 302 | + | {start.why} | |
| 303 | + | </p> | |
| 304 | + | <Link | |
| 305 | + | to={start.row.action.to} | |
| 306 | + | className="mt-4 inline-flex h-8 items-center gap-1.5 rounded-lg bg-accent px-3 text-sm font-medium text-bg transition-colors hover:bg-accent-hover" | |
| 307 | + | > | |
| 308 | + | {start.row.action.label} | |
| 309 | + | <ArrowRight size={14} /> | |
| 310 | + | </Link> | |
| 311 | + | </div> | |
| 312 | + | )} | |
| 313 | + | </Card> | |
| 314 | + | ); | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | // --- The agents' work in the span ----------------------------------------------- | |
| 318 | + | ||
| 319 | + | /** Each outcome's mark: status colours, always beside a label in the legend. */ | |
| 320 | + | const MARK: Record<TaskOutcome, string> = { | |
| 321 | + | accepted: "bg-success", | |
| 322 | + | fixed: "bg-warn", | |
| 323 | + | finished: "bg-info", | |
| 324 | + | dropped: "bg-danger", | |
| 325 | + | }; | |
| 326 | + | ||
| 327 | + | const percent = (value: number) => `${Math.round(value * 100)}%`; | |
| 328 | + | ||
| 329 | + | function Stat({ label, value, mark }: { label: string; value: number; mark?: string }) { | |
| 330 | + | return ( | |
| 331 | + | <div className="min-w-0"> | |
| 332 | + | <div className="flex items-center gap-1.5 text-xs text-muted"> | |
| 333 | + | {mark && <span className={`size-2 shrink-0 rounded-[2px] ${mark}`} aria-hidden />} | |
| 334 | + | <span className="truncate">{label}</span> | |
| 335 | + | </div> | |
| 336 | + | <div className="mt-1 text-xl font-semibold text-fg tabular-nums">{value.toLocaleString("en-US")}</div> | |
| 337 | + | </div> | |
| 338 | + | ); | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | /** "in the span" said the way the span is: "since you were last here", "in the last 24 hours". */ | |
| 342 | + | function inSpan(span: Pick<Span, "key" | "note" | "words">): string { | |
| 343 | + | return span.key === "last" && span.note == null ? "since you were last here" : span.words.charAt(0).toLowerCase() + span.words.slice(1); | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | export function SpanCard({ work, span }: { work: SpanWork | null; span: Pick<Span, "key" | "note" | "words"> }) { | |
| 347 | + | if (!work) { | |
| 348 | + | return ( | |
| 349 | + | <section className="rounded-2xl border border-line bg-surface p-5"> | |
| 350 | + | <p className="text-sm text-muted">The agents' work couldn't be read right now. It shows here once Agents answers.</p> | |
| 351 | + | </section> | |
| 352 | + | ); | |
| 353 | + | } | |
| 354 | + | const { counts, rate, before, tasks } = work; | |
| 355 | + | const trend = trendLabel(rate, before); | |
| 356 | + | const up = rate && before ? Math.round(rate.value * 100) - Math.round(before.value * 100) : 0; | |
| 357 | + | // Without Code, nothing is reviewed: no accepted or fixed to count. | |
| 358 | + | const reviewed = work.code === "read"; | |
| 359 | + | const shown = (Object.keys(MARK) as TaskOutcome[]).filter((o) => counts[o] > 0 || (reviewed && (o === "accepted" || o === "fixed"))); | |
| 360 | + | const when = inSpan(span); | |
| 361 | + | return ( | |
| 362 | + | <section className="grid overflow-hidden rounded-2xl border border-line bg-surface md:grid-cols-[minmax(0,15rem)_minmax(0,1fr)]"> | |
| 363 | + | <div className="border-b border-line p-5 md:border-r md:border-b-0"> | |
| 364 | + | {work.code === "no_access" ? ( | |
| 365 | + | <> | |
| 366 | + | <h3 className="text-sm font-semibold text-fg">Finished</h3> | |
| 367 | + | <div className="mt-2 text-5xl font-semibold tracking-tight text-fg tabular-nums">{(counts.accepted + counts.fixed + counts.finished).toLocaleString("en-US")}</div> | |
| 368 | + | <p className="mt-2 text-xs leading-relaxed text-muted">How much was accepted the first time is measured on pull requests, which are part of Code.</p> | |
| 369 | + | </> | |
| 370 | + | ) : ( | |
| 371 | + | <> | |
| 372 | + | <h3 className="text-sm font-semibold text-fg">Accepted first time</h3> | |
| 373 | + | {rate ? ( | |
| 374 | + | <> | |
| 375 | + | <div className="mt-2 text-5xl font-semibold tracking-tight text-fg tabular-nums">{percent(rate.value)}</div> | |
| 376 | + | <p className="mt-2 text-xs text-muted"> | |
| 377 | + | of {rate.of.toLocaleString("en-US")} agent {rate.of === 1 ? "pull request" : "pull requests"} merged or closed {when} | |
| 378 | + | </p> | |
| 379 | + | <p className={`mt-1.5 text-xs font-medium ${up > 0 ? "text-success" : up < 0 ? "text-warn" : "text-muted"}`}> | |
| 380 | + | {trend ?? (work.code === "read" ? "Nothing settled in the 7 days before to compare." : "")} | |
| 381 | + | </p> | |
| 382 | + | </> | |
| 383 | + | ) : ( | |
| 384 | + | <> | |
| 385 | + | <div className="mt-2 text-5xl font-semibold tracking-tight text-faint">—</div> | |
| 386 | + | <p className="mt-2 text-xs leading-relaxed text-muted"> | |
| 387 | + | {work.code === "unavailable" ? "Code didn't answer, so pull requests aren't counted." : `No agent pull request merged or closed ${when}.`} | |
| 388 | + | {before ? ` The 7 days before: ${percent(before.value)} of ${before.of}.` : ""} | |
| 389 | + | </p> | |
| 390 | + | </> | |
| 391 | + | )} | |
| 392 | + | </> | |
| 393 | + | )} | |
| 394 | + | </div> | |
| 395 | + | <div className="min-w-0 p-5"> | |
| 396 | + | <div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> | |
| 397 | + | <Stat label="Tasks settled" value={tasks.length} /> | |
| 398 | + | {reviewed ? ( | |
| 399 | + | <> | |
| 400 | + | <Stat label="Accepted first time" value={counts.accepted} mark={MARK.accepted} /> | |
| 401 | + | <Stat label="Fixed after review" value={counts.fixed} mark={MARK.fixed} /> | |
| 402 | + | </> | |
| 403 | + | ) : ( | |
| 404 | + | <Stat label="Sessions finished" value={counts.finished} mark={MARK.finished} /> | |
| 405 | + | )} | |
| 406 | + | <Stat label="Didn't finish" value={counts.dropped} mark={MARK.dropped} /> | |
| 407 | + | </div> | |
| 408 | + | {tasks.length === 0 ? ( | |
| 409 | + | <p className="mt-5 text-sm text-muted">No agent work settled {when}.</p> | |
| 410 | + | ) : ( | |
| 411 | + | <div className="mt-5"> | |
| 412 | + | <ul className="flex flex-wrap gap-[3px]" aria-label="Agent work that settled"> | |
| 413 | + | {tasks.map((task) => ( | |
| 414 | + | <li key={task.key}> | |
| 415 | + | <Tooltip> | |
| 416 | + | <TooltipTrigger asChild> | |
| 417 | + | {task.to ? ( | |
| 418 | + | <Link to={task.to} aria-label={`${task.title ?? "A session"}: ${OUTCOME_LABEL[task.outcome]}`} className={`block h-6 w-2.5 rounded-[3px] ${MARK[task.outcome]} transition-opacity hover:opacity-70`} /> | |
| 419 | + | ) : ( | |
| 420 | + | <span className={`block h-6 w-2.5 rounded-[3px] ${MARK[task.outcome]}`} /> | |
| 421 | + | )} | |
| 422 | + | </TooltipTrigger> | |
| 423 | + | <TooltipContent> | |
| 424 | + | <span className="block font-medium">{task.title ?? "A session in a conversation you're not in"}</span> | |
| 425 | + | <span className="text-muted"> | |
| 426 | + | {OUTCOME_LABEL[task.outcome]} · {SOURCE_LABEL[task.source]} | |
| 427 | + | </span> | |
| 428 | + | </TooltipContent> | |
| 429 | + | </Tooltip> | |
| 430 | + | </li> | |
| 431 | + | ))} | |
| 432 | + | </ul> | |
| 433 | + | <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted"> | |
| 434 | + | <span className="text-faint">One mark per task, in the order they settled</span> | |
| 435 | + | {shown.map((outcome) => ( | |
| 436 | + | <span key={outcome} className="inline-flex items-center gap-1.5"> | |
| 437 | + | <span className={`size-2 rounded-[2px] ${MARK[outcome]}`} aria-hidden /> | |
| 438 | + | {OUTCOME_LABEL[outcome]} <span className="text-fg-soft tabular-nums">{counts[outcome]}</span> | |
| 439 | + | </span> | |
| 440 | + | ))} | |
| 441 | + | </div> | |
| 442 | + | </div> | |
| 443 | + | )} | |
| 444 | + | {(work.partial || work.sessions === "unavailable" || work.code === "unavailable") && ( | |
| 445 | + | <p className="mt-3 text-xs text-faint"> | |
| 446 | + | {work.sessions === "unavailable" | |
| 447 | + | ? "Agent sessions couldn't be read, so only pull requests are counted. " | |
| 448 | + | : work.code === "unavailable" | |
| 449 | + | ? "Code didn't answer, so only agent sessions are counted. " | |
| 450 | + | : ""} | |
| 451 | + | {work.partial ? "A list was cut short, so some work may be missing." : ""} | |
| 452 | + | </p> | |
| 453 | + | )} | |
| 454 | + | </div> | |
| 455 | + | </section> | |
| 456 | + | ); | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | export function SpanSkeleton() { | |
| 460 | + | return ( | |
| 461 | + | <section className="grid rounded-2xl border border-line bg-surface md:grid-cols-[15rem_1fr]" aria-busy="true"> | |
| 462 | + | <div className="space-y-3 border-b border-line p-5 md:border-r md:border-b-0"> | |
| 463 | + | <Skeleton className="h-3.5 w-32" /> | |
| 464 | + | <Skeleton className="h-12 w-24" /> | |
| 465 | + | <Skeleton className="h-3 w-40" /> | |
| 466 | + | </div> | |
| 467 | + | <div className="space-y-5 p-5"> | |
| 468 | + | <div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> | |
| 469 | + | {[0, 1, 2, 3].map((i) => ( | |
| 470 | + | <div key={i} className="space-y-2"> | |
| 471 | + | <Skeleton className="h-3 w-20" /> | |
| 472 | + | <Skeleton className="h-5 w-8" /> | |
| 473 | + | </div> | |
| 474 | + | ))} | |
| 475 | + | </div> | |
| 476 | + | <Skeleton className="h-6 w-3/4" /> | |
| 477 | + | </div> | |
| 478 | + | </section> | |
| 479 | + | ); | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | export function Sources({ work }: { work: SpanWork | null }) { | |
| 483 | + | if (!work || work.sources.length === 0) return null; | |
| 484 | + | return ( | |
| 485 | + | <Card title="Where the work came from"> | |
| 486 | + | <ul className="flex flex-wrap gap-2 px-5 pb-5"> | |
| 487 | + | {work.sources.map(({ source, count }) => ( | |
| 488 | + | <li key={source} className="inline-flex items-center gap-2 rounded-lg border border-line bg-raised/40 px-2.5 py-1.5 text-xs"> | |
| 489 | + | <span className="text-muted">{SOURCE_LABEL[source]}</span> | |
| 490 | + | <span className="font-semibold text-fg tabular-nums">{count.toLocaleString("en-US")}</span> | |
| 491 | + | </li> | |
| 492 | + | ))} | |
| 493 | + | </ul> | |
| 494 | + | </Card> | |
| 495 | + | ); | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | // --- Landed, builds and decisions ------------------------------------------------- | |
| 499 | + | ||
| 500 | + | /** Rows each list shows before the rest fold away. */ | |
| 501 | + | const LIST_ROWS = 5; | |
| 502 | + | ||
| 503 | + | function When({ at, now, tz }: { at: number; now: number; tz: string | null }) { | |
| 504 | + | return ( | |
| 505 | + | <span className="shrink-0 text-xs text-faint tabular-nums" suppressHydrationWarning> | |
| 506 | + | {whenShort(at, now, tz)} | |
| 507 | + | </span> | |
| 508 | + | ); | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | function Folded<T>({ items, render }: { items: T[]; render: (item: T) => ReactNode }) { | |
| 512 | + | return ( | |
| 513 | + | <> | |
| 514 | + | <ul>{items.slice(0, LIST_ROWS).map(render)}</ul> | |
| 515 | + | {items.length > LIST_ROWS && ( | |
| 516 | + | <details className="group border-t border-line"> | |
| 517 | + | <summary className="cursor-pointer list-none px-5 py-2.5 text-xs font-medium text-fg-soft hover:text-fg"> | |
| 518 | + | <span className="group-open:hidden">Show {(items.length - LIST_ROWS).toLocaleString("en-US")} more</span> | |
| 519 | + | <span className="hidden group-open:inline">Show fewer</span> | |
| 520 | + | </summary> | |
| 521 | + | <ul className="border-t border-line">{items.slice(LIST_ROWS).map(render)}</ul> | |
| 522 | + | </details> | |
| 523 | + | )} | |
| 524 | + | </> | |
| 525 | + | ); | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | const rowClass = "flex items-center gap-3 border-t border-line px-5 py-2.5 first:border-t-0"; | |
| 529 | + | ||
| 530 | + | export function LandedCard({ landed, code, now, tz }: { landed: LandedChange[] | null; code: boolean; now: number; tz: string | null }) { | |
| 531 | + | if (!code) return null; | |
| 532 | + | const agents = landed?.filter((change) => change.agent).length ?? 0; | |
| 533 | + | return ( | |
| 534 | + | <Card title="Landed" hint={landed && landed.length > 0 ? `${landed.length.toLocaleString("en-US")} merged · ${agents.toLocaleString("en-US")} by agents` : undefined}> | |
| 535 | + | {!landed ? ( | |
| 536 | + | <Quiet>Code didn't answer, so what landed can't be listed right now.</Quiet> | |
| 537 | + | ) : landed.length === 0 ? ( | |
| 538 | + | <Quiet>Nothing merged in this span.</Quiet> | |
| 539 | + | ) : ( | |
| 540 | + | <Folded | |
| 541 | + | items={landed} | |
| 542 | + | render={(change) => ( | |
| 543 | + | <li key={change.key} className={rowClass}> | |
| 544 | + | <GitMerge size={15} className={cn("shrink-0", change.agent ? "text-accent" : "text-success")} /> | |
| 545 | + | <div className="min-w-0 grow"> | |
| 546 | + | <Link to={change.to} className="block truncate text-sm text-fg hover:underline"> | |
| 547 | + | {change.title} | |
| 548 | + | </Link> | |
| 549 | + | <p className="truncate text-xs text-muted"> | |
| 550 | + | {change.repo.name}#{change.number} · {change.agent ? `by @${change.author}` : `by ${change.author}`} | |
| 551 | + | {change.mergedBy && change.mergedBy !== change.author ? `, merged by ${change.mergedBy}` : ""} | |
| 552 | + | </p> | |
| 553 | + | </div> | |
| 554 | + | <When at={change.at} now={now} tz={tz} /> | |
| 555 | + | </li> | |
| 556 | + | )} | |
| 557 | + | /> | |
| 558 | + | )} | |
| 559 | + | </Card> | |
| 560 | + | ); | |
| 561 | + | } | |
| 562 | + | ||
| 563 | + | export function DeploysCard({ deploys, complete, code, now, tz }: { deploys: DeployRow[] | null; complete: boolean; code: boolean; now: number; tz: string | null }) { | |
| 564 | + | // A workspace that doesn't deploy has nothing to say here. | |
| 565 | + | if (!code || (deploys && deploys.length === 0)) return null; | |
| 566 | + | const failed = deploys?.filter((row) => row.outcome === "failed").length ?? 0; | |
| 567 | + | return ( | |
| 568 | + | <Card title="Deploys" hint={deploys ? `${deploys.length.toLocaleString("en-US")} finished${failed ? ` · ${failed} failed` : ""}` : undefined}> | |
| 569 | + | {!deploys ? ( | |
| 570 | + | <Quiet>Deployments didn't answer, so builds can't be listed right now.</Quiet> | |
| 571 | + | ) : ( | |
| 572 | + | <Folded | |
| 573 | + | items={deploys} | |
| 574 | + | render={(row) => ( | |
| 575 | + | <li key={row.key} className={rowClass}> | |
| 576 | + | <Rocket size={15} className={cn("shrink-0", row.outcome === "failed" ? "text-danger" : "text-success")} /> | |
| 577 | + | <div className="min-w-0 grow"> | |
| 578 | + | <Link to={row.to} className="block truncate text-sm text-fg hover:underline"> | |
| 579 | + | {row.kind === "production" ? "Production" : `Preview of ${row.branch ?? "a branch"}`} of {row.project} | |
| 580 | + | <span className={cn("ml-2 text-xs font-medium", row.outcome === "failed" ? "text-danger" : "text-success")}> | |
| 581 | + | {row.outcome === "failed" ? "Failed" : "Went live"} | |
| 582 | + | </span> | |
| 583 | + | </Link> | |
| 584 | + | <p className="truncate text-xs text-muted"> | |
| 585 | + | <span className="font-mono">{row.commit}</span> · by {row.by} | |
| 586 | + | </p> | |
| 587 | + | </div> | |
| 588 | + | <When at={row.at} now={now} tz={tz} /> | |
| 589 | + | </li> | |
| 590 | + | )} | |
| 591 | + | /> | |
| 592 | + | )} | |
| 593 | + | {deploys && !complete && <p className="border-t border-line px-5 py-2.5 text-xs text-faint">Some projects' builds couldn't be read.</p>} | |
| 594 | + | </Card> | |
| 595 | + | ); | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | export function DecisionsCard({ | |
| 599 | + | decisions, | |
| 600 | + | read, | |
| 601 | + | now, | |
| 602 | + | tz, | |
| 603 | + | }: { | |
| 604 | + | decisions: DecisionRow[] | null; | |
| 605 | + | read: { memories: boolean; requests: boolean } | null; | |
| 606 | + | now: number; | |
| 607 | + | tz: string | null; | |
| 608 | + | }) { | |
| 609 | + | if (!decisions || decisions.length === 0) return null; | |
| 610 | + | const missing = [...(read && !read.memories ? ["Memory"] : []), ...(read && !read.requests ? ["The Marketplace"] : [])]; | |
| 611 | + | return ( | |
| 612 | + | <Card title="Decisions" hint="Recorded in the workspace's memory, and requests answered"> | |
| 613 | + | <Folded | |
| 614 | + | items={decisions} | |
| 615 | + | render={(row) => ( | |
| 616 | + | <li key={row.key} className={rowClass}> | |
| 617 | + | {row.kind === "memory" ? <Lightbulb size={15} className="shrink-0 text-warn" /> : <Store size={15} className="shrink-0 text-info" />} | |
| 618 | + | <div className="min-w-0 grow"> | |
| 619 | + | <Link to={row.to} className="line-clamp-2 text-sm text-fg hover:underline"> | |
| 620 | + | {row.text} | |
| 621 | + | </Link> | |
| 622 | + | {row.by && <p className="truncate text-xs text-muted">by {row.by}</p>} | |
| 623 | + | </div> | |
| 624 | + | <When at={row.at} now={now} tz={tz} /> | |
| 625 | + | </li> | |
| 626 | + | )} | |
| 627 | + | /> | |
| 628 | + | <Missing names={missing} what="every decision" /> | |
| 629 | + | </Card> | |
| 630 | + | ); | |
| 631 | + | } | |
| 632 | + | ||
| 633 | + | // --- Spend in the span ------------------------------------------------------------ | |
| 634 | + | ||
| 635 | + | /** | |
| 636 | + | * Each kind of charge keeps its colour, whatever its rank: models one, | |
| 637 | + | * sandboxes and runners another, hosting a third, storage and the rest grey. | |
| 638 | + | */ | |
| 639 | + | function kindColour(kind: string): string { | |
| 640 | + | if (/agent|model|gateway/i.test(kind)) return "bg-accent"; | |
| 641 | + | if (/sandbox|runner/i.test(kind)) return "bg-info"; | |
| 642 | + | if (/deploy|domain/i.test(kind)) return "bg-fg-soft"; | |
| 643 | + | if (/storage|git|cache|package/i.test(kind)) return "bg-muted"; | |
| 644 | + | return "bg-faint"; | |
| 645 | + | } | |
| 646 | + | ||
| 647 | + | const utcDay = (day: string) => new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" }).format(Date.parse(`${day}T12:00:00Z`)); | |
| 648 | + | ||
| 649 | + | export function SpendCard({ spend, slug, span }: { spend: Spend | null; slug: string; span: Pick<Span, "key" | "note" | "words"> }) { | |
| 650 | + | return ( | |
| 651 | + | <Card title="Spent" link={{ label: "Billing", to: `/${slug}/-/billing` }}> | |
| 652 | + | {!spend ? ( | |
| 653 | + | <Quiet>Billing didn't answer, so spend can't be shown right now.</Quiet> | |
| 654 | + | ) : ( | |
| 655 | + | <div className="px-5 pb-5"> | |
| 656 | + | <div className="text-3xl font-semibold tracking-tight text-fg tabular-nums">{dollars(spend.totalMicros)}</div> | |
| 657 | + | <p className="mt-1 text-xs text-muted"> | |
| 658 | + | {spend.from === spend.to ? utcDay(spend.from) : `${utcDay(spend.from)} – ${utcDay(spend.to)}`} · {dollars(spend.monthMicros)} this month so far | |
| 659 | + | </p> | |
| 660 | + | {spend.lines.length === 0 ? ( | |
| 661 | + | <p className="mt-4 text-sm text-muted">Nothing on the statement {inSpan(span)}.</p> | |
| 662 | + | ) : ( | |
| 663 | + | <> | |
| 664 | + | <div className="mt-4 flex h-2.5 gap-[2px] overflow-hidden rounded-full" role="img" aria-label="Spend by kind of charge"> | |
| 665 | + | {spend.lines.map((line) => ( | |
| 666 | + | <span key={line.kind} className={`h-full min-w-[3px] ${kindColour(line.kind)}`} style={{ flexGrow: line.micros }} /> | |
| 667 | + | ))} | |
| 668 | + | </div> | |
| 669 | + | <ul className="mt-3 space-y-2"> | |
| 670 | + | {spend.lines.map((line) => ( | |
| 671 | + | <li key={line.kind} className="flex items-center gap-2 text-sm"> | |
| 672 | + | <span className={`size-2 shrink-0 rounded-[2px] ${kindColour(line.kind)}`} aria-hidden /> | |
| 673 | + | <span className="min-w-0 grow truncate text-fg-soft">{line.kind}</span> | |
| 674 | + | <span className="text-fg tabular-nums">{dollars(line.micros)}</span> | |
| 675 | + | </li> | |
| 676 | + | ))} | |
| 677 | + | </ul> | |
| 678 | + | </> | |
| 679 | + | )} | |
| 680 | + | <p className="mt-4 text-[0.6875rem] leading-relaxed text-faint"> | |
| 681 | + | At price, as your statement shows it. The statement keeps whole days, midnight to midnight UTC, so this counts from the start of the day the span began. | |
| 682 | + | </p> | |
| 683 | + | </div> | |
| 684 | + | )} | |
| 685 | + | </Card> | |
| 686 | + | ); | |
| 687 | + | } | |
| 688 | + | ||
| 689 | + | export function SpendSkeleton() { | |
| 690 | + | return ( | |
| 691 | + | <div className="space-y-3 px-5 pb-5" aria-busy="true"> | |
| 692 | + | <Skeleton className="h-8 w-28" /> | |
| 693 | + | <Skeleton className="h-2.5 w-full rounded-full" /> | |
| 694 | + | <Skeleton className="h-3 w-3/4" /> | |
| 695 | + | <Skeleton className="h-3 w-2/3" /> | |
| 696 | + | </div> | |
| 697 | + | ); | |
| 698 | + | } | |
| 699 | + | ||
| 700 | + | // --- Running now ------------------------------------------------------------------- | |
| 701 | + | ||
| 702 | + | const RUNNING_ICON: Record<RunningRow["kind"], ReactNode> = { | |
| 703 | + | session: <Sparkles size={15} className="text-accent" />, | |
| 704 | + | change: <GitPullRequest size={15} className="text-info" />, | |
| 705 | + | deploy: <Rocket size={15} className="text-info" />, | |
| 706 | + | }; | |
| 707 | + | ||
| 708 | + | export function RunningList({ rows, missing, now }: { rows: RunningRow[]; missing: string[]; now: number }) { | |
| 709 | + | return ( | |
| 710 | + | <> | |
| 711 | + | {rows.length === 0 ? ( | |
| 712 | + | <Quiet>{missing.length > 0 ? "Nothing is running in what could be read." : "Nothing is running. Agent sessions, agents' changes and builds show here while they go."}</Quiet> | |
| 713 | + | ) : ( | |
| 714 | + | <Folded | |
| 715 | + | items={rows} | |
| 716 | + | render={(row) => ( | |
| 717 | + | <li key={row.key} className={rowClass}> | |
| 718 | + | <span className="shrink-0">{RUNNING_ICON[row.kind]}</span> | |
| 719 | + | <div className="min-w-0 grow"> | |
| 720 | + | <Link to={row.to} className="block truncate text-sm text-fg hover:underline"> | |
| 721 | + | {row.title} | |
| 722 | + | </Link> | |
| 723 | + | <p className="truncate text-xs text-muted">{row.detail}</p> | |
| 724 | + | </div> | |
| 725 | + | <span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-raised px-2 py-0.5 text-xs text-fg-soft"> | |
| 726 | + | <Loader size={11} className="animate-spin text-accent motion-reduce:animate-none" aria-hidden /> | |
| 727 | + | {row.status} | |
| 728 | + | </span> | |
| 729 | + | <span className="hidden w-14 shrink-0 text-right text-xs text-faint tabular-nums sm:inline" suppressHydrationWarning> | |
| 730 | + | {waited(row.at, now)} | |
| 731 | + | </span> | |
| 732 | + | </li> | |
| 733 | + | )} | |
| 734 | + | /> | |
| 735 | + | )} | |
| 736 | + | <Missing names={missing} what="everything running" /> | |
| 737 | + | </> | |
| 738 | + | ); | |
| 739 | + | } |
| 119 | 119 | type Tab = { key: ModeKey; label: string; badge?: { count: number; loud: boolean } }; | |
| 120 | 120 | ||
| 121 | 121 | /** | |
| 122 | − | * The bottom bar of a phone, floating 8px from the edges: Today, Chat, | |
| 122 | + | * The bottom bar of a phone, floating 8px from the edges: Home, Chat, | |
| 123 | 123 | * Notifications, Agents and Code (for a member with Code access), each | |
| 124 | 124 | * with what is unread, and More. It steps aside while the keyboard is up | |
| 125 | 125 | * and inside a conversation. The tab you are in, tapped again, opens its | |
| ⋯ | |||
| 147 | 147 | const code = hasCodeAccess(workspace); | |
| 148 | 148 | const mode = modeOf(pathname, slug); | |
| 149 | 149 | const tabs: Tab[] = [ | |
| 150 | − | { key: "today", label: "Today" }, | |
| 150 | + | { key: "home", label: "Home" }, | |
| 151 | 151 | { key: "chat", label: "Chat", badge: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 } }, | |
| 152 | 152 | { key: "notifications", label: "Notifications", badge: { count: unread.notifications, loud: true } }, | |
| 153 | 153 | { key: "agents", label: "Agents" }, | |
| 13 | 13 | * per step, chosen with the pills. | |
| 14 | 14 | * | |
| 15 | 15 | * It is an honest miniature of the app: the same rail (the workspace on | |
| 16 | − | * the top bar's line, then Today, Code, Chat, Docs, Agents and Notifications, | |
| 16 | + | * the top bar's line, then Home, Code, Chat, Docs, Agents and Notifications, | |
| 17 | 17 | * with names), each mode's sidebar with its real sections and words, the | |
| 18 | 18 | * same top bar (where you are, Explore, Docs, Ask g1t, the inbox and +), | |
| 19 | 19 | * which Chat gives up to the conversation's own header as the app does, | |
| ⋯ | |||
| 44 | 44 | GitMerge, | |
| 45 | 45 | GitPullRequest, | |
| 46 | 46 | Hash, | |
| 47 | − | Sun, | |
| 47 | + | House, | |
| 48 | 48 | MessagesSquare, | |
| 49 | 49 | Network, | |
| 50 | 50 | Paperclip, | |
| ⋯ | |||
| 348 | 348 | ||
| 349 | 349 | /** The rail's modes, in the app's order (components/rail.tsx), with its icons. */ | |
| 350 | 350 | const RAIL: { key: string; icon: ReactNode; label: string; target?: CursorTarget; scene?: Scene; badge?: number }[] = [ | |
| 351 | − | { key: "home", icon: <Sun size={15} />, label: "Today" }, | |
| 351 | + | { key: "home", icon: <House size={15} />, label: "Home" }, | |
| 352 | 352 | { key: "code", icon: <Code2 size={15} />, label: "Code", target: "rail-code", scene: "code" }, | |
| 353 | 353 | { key: "chat", icon: <MessagesSquare size={15} />, label: "Chat", target: "rail-chat", scene: "chat", badge: 3 }, | |
| 354 | 354 | { key: "artifacts", icon: <Shapes size={15} />, label: "Artifacts", target: "rail-docs", scene: "docs" }, | |
| ⋯ | |||
| 1286 | 1286 | ||
| 1287 | 1287 | /** The phone's tab bar, as the app has it (components/mobile.tsx). */ | |
| 1288 | 1288 | const TABS: { scene: Scene | null; icon: ReactNode; label: string }[] = [ | |
| 1289 | − | { scene: null, icon: <Sun size={16} />, label: "Today" }, | |
| 1289 | + | { scene: null, icon: <House size={16} />, label: "Home" }, | |
| 1290 | 1290 | { scene: "code", icon: <Code2 size={16} />, label: "Code" }, | |
| 1291 | 1291 | { scene: "chat", icon: <MessagesSquare size={16} />, label: "Chat" }, | |
| 1292 | 1292 | { scene: "agents", icon: <Sparkles size={16} />, label: "Agents" }, | |
| 1 | − | import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Bot, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Coins, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, KanbanSquare, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogOut, Mail, Network, Package, PanelLeft, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, Shapes, ShieldCheck, Scale, Smile, Sparkles, Store, Sun, Ticket, TrendingUp, UserPlus, UserRoundKey, Users, UsersRound, Webhook, X, ArrowLeftRight } from "lucide-react"; | |
| 1 | + | import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Bot, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Coins, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, KanbanSquare, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogOut, Mail, Network, Package, PanelLeft, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, Shapes, ShieldCheck, Scale, Smile, Sparkles, Store, House, Ticket, TrendingUp, UserPlus, UserRoundKey, Users, UsersRound, Webhook, X, ArrowLeftRight } from "lucide-react"; | |
| 2 | 2 | import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 25 | 25 | DropdownMenuTrigger, | |
| 26 | 26 | } from "./ui/dropdown-menu"; | |
| 27 | 27 | import { type RoadmapItem, roadmapIn, roadmapItem } from "../lib/roadmap"; | |
| 28 | − | import { type ModeKey, SETTINGS_PAGES, modeOf, todayPath } from "../lib/workspace-nav"; | |
| 28 | + | import { type ModeKey, SETTINGS_PAGES, homePagePath, modeOf } from "../lib/workspace-nav"; | |
| 29 | 29 | import { AgentsSidebar } from "./agents-mode"; | |
| 30 | 30 | import { ChatSidebar } from "./chat/sidebar"; | |
| 31 | 31 | import { FoliosSidebar } from "./folios/sidebar"; | |
| ⋯ | |||
| 1423 | 1423 | sessions: "Sessions", | |
| 1424 | 1424 | chat: "Chat", | |
| 1425 | 1425 | artifacts: "Artifacts", | |
| 1426 | − | today: "Today", | |
| 1426 | + | home: "Home", | |
| 1427 | 1427 | apps: "Apps", | |
| 1428 | 1428 | marketplace: "Marketplace", | |
| 1429 | 1429 | "code-access": "Code access", | |
| ⋯ | |||
| 1455 | 1455 | } | |
| 1456 | 1456 | const parts = pathname.split("/").filter(Boolean); | |
| 1457 | 1457 | const reserved = ["settings", "explore", "new", "search", "workspaces", "policies", "security", "support", "status", "invite", "notifications"]; | |
| 1458 | − | if (parts.length === 0) return <span className="text-sm font-medium">Today</span>; | |
| 1458 | + | if (parts.length === 0) return <span className="text-sm font-medium">Home</span>; | |
| 1459 | 1459 | // Your settings: Settings / Emails. | |
| 1460 | 1460 | if (parts[0] === "settings") { | |
| 1461 | 1461 | const page = accountSettingsPage(pathname); | |
| ⋯ | |||
| 1584 | 1584 | const commands: Command[] = [ | |
| 1585 | 1585 | ...(ws | |
| 1586 | 1586 | ? [ | |
| 1587 | − | { label: "Today", to: todayPath(ws.slug), icon: <Sun size={15} /> }, | |
| 1587 | + | { label: "Home", to: homePagePath(ws.slug), icon: <House size={15} /> }, | |
| 1588 | 1588 | { label: "Chat", to: `/${ws.slug}/-/chat`, icon: <MessagesSquare size={15} /> }, | |
| 1589 | 1589 | { label: "Agents", to: `/${ws.slug}/-/agents`, icon: <Sparkles size={15} /> }, | |
| 1590 | 1590 | ...(hasCodeAccess(ws) ? [{ label: "Code overview", to: `/${ws.slug}/-/overview`, icon: <Code2 size={15} /> }] : []), | |
| ⋯ | |||
| 1742 | 1742 | } | |
| 1743 | 1743 | ||
| 1744 | 1744 | /** | |
| 1745 | − | * Which sidebar sits beside the page: each mode's own, or none. Today and | |
| 1745 | + | * Which sidebar sits beside the page: each mode's own, or none. Home and | |
| 1746 | 1746 | * Apps are pages at full width, and so are g1t's public pages, such as a | |
| 1747 | 1747 | * profile, which are no workspace's. Without a workspace, only your own | |
| 1748 | 1748 | * settings and Notifications have one. | |
| 1749 | 1749 | */ | |
| 1750 | 1750 | function sidebarFor(mode: ModeKey, workspace: boolean): Panel | null { | |
| 1751 | 1751 | if (!workspace) return mode === "account" || mode === "notifications" ? mode : null; | |
| 1752 | − | if (mode === "today" || mode === "apps" || mode === "site") return null; | |
| 1752 | + | if (mode === "home" || mode === "apps" || mode === "site") return null; | |
| 1753 | 1753 | return mode; | |
| 1754 | 1754 | } | |
| 1755 | 1755 | ||
| 1 | − | /** | |
| 2 | − | * Today's parts (routes/workspace/home.tsx): the day's agent work, Needs | |
| 3 | − | * attention, Spent today and Start here. Each takes what lib/today.ts | |
| 4 | − | * worked out and only draws it. | |
| 5 | − | */ | |
| 6 | − | import { | |
| 7 | − | AlertTriangle, | |
| 8 | − | ArrowRight, | |
| 9 | − | AtSign, | |
| 10 | − | Bell, | |
| 11 | − | Bot, | |
| 12 | − | CircleDollarSign, | |
| 13 | − | GitMerge, | |
| 14 | − | GitPullRequest, | |
| 15 | − | Hourglass, | |
| 16 | − | Mail, | |
| 17 | − | Rocket, | |
| 18 | − | Server, | |
| 19 | − | Sparkles, | |
| 20 | − | User, | |
| 21 | − | } from "lucide-react"; | |
| 22 | − | import type { ReactNode } from "react"; | |
| 23 | − | import { Link } from "react-router"; | |
| 24 | − | ||
| 25 | − | import { | |
| 26 | − | type Attention, | |
| 27 | − | type AttentionKind, | |
| 28 | − | type AttentionRow, | |
| 29 | − | type DayWork, | |
| 30 | − | type Spend, | |
| 31 | − | type Stake, | |
| 32 | − | type TaskOutcome, | |
| 33 | − | OUTCOME_LABEL, | |
| 34 | − | SOURCE_LABEL, | |
| 35 | − | dollars, | |
| 36 | − | trendLabel, | |
| 37 | − | waited, | |
| 38 | − | } from "../lib/today"; | |
| 39 | − | import { Skeleton } from "./ui/skeleton"; | |
| 40 | − | import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; | |
| 41 | − | ||
| 42 | − | export function Card({ title, link, children, className = "" }: { title: ReactNode; link?: { label: string; to: string } | null; children: ReactNode; className?: string }) { | |
| 43 | − | return ( | |
| 44 | − | <section className={`rounded-2xl border border-line bg-surface ${className}`}> | |
| 45 | − | <header className="flex items-center gap-2 px-5 pt-4 pb-3"> | |
| 46 | − | <h2 className="text-sm font-semibold text-fg">{title}</h2> | |
| 47 | − | {link && ( | |
| 48 | − | <Link to={link.to} className="ml-auto inline-flex items-center gap-1 text-xs text-muted hover:text-fg"> | |
| 49 | − | {link.label} | |
| 50 | − | <ArrowRight size={12} /> | |
| 51 | − | </Link> | |
| 52 | − | )} | |
| 53 | − | </header> | |
| 54 | − | {children} | |
| 55 | − | </section> | |
| 56 | − | ); | |
| 57 | − | } | |
| 58 | − | ||
| 59 | − | export function Quiet({ children }: { children: ReactNode }) { | |
| 60 | − | return <p className="px-5 pb-5 text-sm text-muted">{children}</p>; | |
| 61 | − | } | |
| 62 | − | ||
| 63 | − | // --- The day's work ----------------------------------------------------------- | |
| 64 | − | ||
| 65 | − | /** Each outcome's mark: status colours, always beside a label in the legend. */ | |
| 66 | − | const MARK: Record<TaskOutcome, string> = { | |
| 67 | − | accepted: "bg-success", | |
| 68 | − | fixed: "bg-warn", | |
| 69 | − | finished: "bg-info", | |
| 70 | − | open: "bg-accent/45", | |
| 71 | − | dropped: "bg-danger", | |
| 72 | − | }; | |
| 73 | − | ||
| 74 | − | const percent = (value: number) => `${Math.round(value * 100)}%`; | |
| 75 | − | ||
| 76 | − | function Stat({ label, value, mark }: { label: string; value: number; mark?: string }) { | |
| 77 | − | return ( | |
| 78 | − | <div className="min-w-0"> | |
| 79 | − | <div className="flex items-center gap-1.5 text-xs text-muted"> | |
| 80 | − | {mark && <span className={`size-2 shrink-0 rounded-[2px] ${mark}`} aria-hidden />} | |
| 81 | − | <span className="truncate">{label}</span> | |
| 82 | − | </div> | |
| 83 | − | <div className="mt-1 text-xl font-semibold text-fg tabular-nums">{value.toLocaleString("en-US")}</div> | |
| 84 | − | </div> | |
| 85 | − | ); | |
| 86 | − | } | |
| 87 | − | ||
| 88 | − | export function DayCard({ work }: { work: DayWork | null }) { | |
| 89 | − | if (!work) { | |
| 90 | − | return ( | |
| 91 | − | <section className="rounded-2xl border border-line bg-surface p-5"> | |
| 92 | − | <p className="text-sm text-muted">Today's agent work couldn't be read right now. It shows here once Agents answers.</p> | |
| 93 | − | </section> | |
| 94 | − | ); | |
| 95 | − | } | |
| 96 | − | const { counts, rate, lastWeek, tasks } = work; | |
| 97 | − | const trend = trendLabel(rate, lastWeek); | |
| 98 | − | const up = rate && lastWeek ? Math.round(rate.value * 100) - Math.round(lastWeek.value * 100) : 0; | |
| 99 | − | // Without Code, nothing is reviewed: no accepted or fixed to count. | |
| 100 | − | const reviewed = work.code === "read"; | |
| 101 | − | const shown = (Object.keys(MARK) as TaskOutcome[]).filter((o) => counts[o] > 0 || (reviewed && (o === "accepted" || o === "fixed")) || o === "open"); | |
| 102 | − | return ( | |
| 103 | − | <section className="grid overflow-hidden rounded-2xl border border-line bg-surface md:grid-cols-[minmax(0,15rem)_minmax(0,1fr)]"> | |
| 104 | − | <div className="border-b border-line p-5 md:border-r md:border-b-0"> | |
| 105 | − | {work.code === "no_access" ? ( | |
| 106 | − | <> | |
| 107 | − | <h2 className="text-sm font-semibold text-fg">Finished today</h2> | |
| 108 | − | <div className="mt-2 text-5xl font-semibold tracking-tight text-fg tabular-nums">{(counts.accepted + counts.fixed + counts.finished).toLocaleString("en-US")}</div> | |
| 109 | − | <p className="mt-2 text-xs leading-relaxed text-muted">How much was accepted the first time is measured on pull requests, which are part of Code.</p> | |
| 110 | − | </> | |
| 111 | − | ) : ( | |
| 112 | − | <> | |
| 113 | − | <h2 className="text-sm font-semibold text-fg">Accepted first time</h2> | |
| 114 | − | {rate ? ( | |
| 115 | − | <> | |
| 116 | − | <div className="mt-2 text-5xl font-semibold tracking-tight text-fg tabular-nums">{percent(rate.value)}</div> | |
| 117 | − | <p className="mt-2 text-xs text-muted"> | |
| 118 | − | of {rate.of.toLocaleString("en-US")} agent {rate.of === 1 ? "pull request" : "pull requests"} merged or closed today | |
| 119 | − | </p> | |
| 120 | − | <p className={`mt-1.5 text-xs font-medium ${up > 0 ? "text-success" : up < 0 ? "text-warn" : "text-muted"}`}> | |
| 121 | − | {trend ?? (work.code === "read" ? "Nothing settled in the last 7 days to compare." : "")} | |
| 122 | − | </p> | |
| 123 | − | </> | |
| 124 | − | ) : ( | |
| 125 | − | <> | |
| 126 | − | <div className="mt-2 text-5xl font-semibold tracking-tight text-faint">—</div> | |
| 127 | − | <p className="mt-2 text-xs leading-relaxed text-muted"> | |
| 128 | − | {work.code === "unavailable" ? "Code didn't answer, so pull requests aren't counted." : "No agent pull request has merged or closed today yet."} | |
| 129 | − | {lastWeek ? ` The last 7 days: ${percent(lastWeek.value)} of ${lastWeek.of}.` : ""} | |
| 130 | − | </p> | |
| 131 | − | </> | |
| 132 | − | )} | |
| 133 | − | </> | |
| 134 | − | )} | |
| 135 | − | </div> | |
| 136 | − | <div className="min-w-0 p-5"> | |
| 137 | − | <div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> | |
| 138 | − | <Stat label="Tasks today" value={tasks.length} /> | |
| 139 | − | {reviewed ? ( | |
| 140 | − | <> | |
| 141 | − | <Stat label="Accepted first time" value={counts.accepted} mark={MARK.accepted} /> | |
| 142 | − | <Stat label="Fixed after review" value={counts.fixed} mark={MARK.fixed} /> | |
| 143 | − | </> | |
| 144 | − | ) : ( | |
| 145 | − | <> | |
| 146 | − | <Stat label="Sessions finished" value={counts.finished} mark={MARK.finished} /> | |
| 147 | − | <Stat label="Didn't finish" value={counts.dropped} mark={MARK.dropped} /> | |
| 148 | − | </> | |
| 149 | − | )} | |
| 150 | − | <Stat label="Still open" value={counts.open} mark={MARK.open} /> | |
| 151 | − | </div> | |
| 152 | − | {tasks.length === 0 ? ( | |
| 153 | − | <p className="mt-5 text-sm text-muted">No agent work today yet.</p> | |
| 154 | − | ) : ( | |
| 155 | − | <div className="mt-5"> | |
| 156 | − | <ul className="flex flex-wrap gap-[3px]" aria-label="Today's tasks"> | |
| 157 | − | {tasks.map((task) => ( | |
| 158 | − | <li key={task.key}> | |
| 159 | − | <Tooltip> | |
| 160 | − | <TooltipTrigger asChild> | |
| 161 | − | {task.to ? ( | |
| 162 | − | <Link to={task.to} aria-label={`${task.title ?? "A session"}: ${OUTCOME_LABEL[task.outcome]}`} className={`block h-6 w-2.5 rounded-[3px] ${MARK[task.outcome]} transition-opacity hover:opacity-70`} /> | |
| 163 | − | ) : ( | |
| 164 | − | <span className={`block h-6 w-2.5 rounded-[3px] ${MARK[task.outcome]}`} /> | |
| 165 | − | )} | |
| 166 | − | </TooltipTrigger> | |
| 167 | − | <TooltipContent> | |
| 168 | − | <span className="block font-medium">{task.title ?? "A session in a conversation you're not in"}</span> | |
| 169 | − | <span className="text-muted"> | |
| 170 | − | {OUTCOME_LABEL[task.outcome]} · {SOURCE_LABEL[task.source]} | |
| 171 | − | </span> | |
| 172 | − | </TooltipContent> | |
| 173 | − | </Tooltip> | |
| 174 | − | </li> | |
| 175 | − | ))} | |
| 176 | − | </ul> | |
| 177 | − | <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted"> | |
| 178 | − | <span className="text-faint">Every mark is one of today's tasks</span> | |
| 179 | − | {shown.map((outcome) => ( | |
| 180 | − | <span key={outcome} className="inline-flex items-center gap-1.5"> | |
| 181 | − | <span className={`size-2 rounded-[2px] ${MARK[outcome]}`} aria-hidden /> | |
| 182 | − | {OUTCOME_LABEL[outcome]} <span className="text-fg-soft tabular-nums">{counts[outcome]}</span> | |
| 183 | − | </span> | |
| 184 | − | ))} | |
| 185 | − | </div> | |
| 186 | − | </div> | |
| 187 | − | )} | |
| 188 | − | {(work.partial || work.sessions === "unavailable" || work.code === "unavailable") && ( | |
| 189 | − | <p className="mt-3 text-xs text-faint"> | |
| 190 | − | {work.sessions === "unavailable" | |
| 191 | − | ? "Agent sessions couldn't be read, so only pull requests are counted. " | |
| 192 | − | : work.code === "unavailable" | |
| 193 | − | ? "Code didn't answer, so only agent sessions are counted. " | |
| 194 | − | : ""} | |
| 195 | − | {work.partial ? "A list was cut short, so some of today's tasks may be missing." : ""} | |
| 196 | − | </p> | |
| 197 | − | )} | |
| 198 | − | </div> | |
| 199 | − | </section> | |
| 200 | − | ); | |
| 201 | − | } | |
| 202 | − | ||
| 203 | − | export function DaySkeleton() { | |
| 204 | − | return ( | |
| 205 | − | <section className="grid rounded-2xl border border-line bg-surface md:grid-cols-[15rem_1fr]" aria-busy="true"> | |
| 206 | − | <div className="space-y-3 border-b border-line p-5 md:border-r md:border-b-0"> | |
| 207 | − | <Skeleton className="h-3.5 w-32" /> | |
| 208 | − | <Skeleton className="h-12 w-24" /> | |
| 209 | − | <Skeleton className="h-3 w-40" /> | |
| 210 | − | </div> | |
| 211 | − | <div className="space-y-5 p-5"> | |
| 212 | − | <div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> | |
| 213 | − | {[0, 1, 2, 3].map((i) => ( | |
| 214 | − | <div key={i} className="space-y-2"> | |
| 215 | − | <Skeleton className="h-3 w-20" /> | |
| 216 | − | <Skeleton className="h-5 w-8" /> | |
| 217 | − | </div> | |
| 218 | − | ))} | |
| 219 | − | </div> | |
| 220 | − | <Skeleton className="h-6 w-3/4" /> | |
| 221 | − | </div> | |
| 222 | − | </section> | |
| 223 | − | ); | |
| 224 | − | } | |
| 225 | − | ||
| 226 | − | // --- Needs attention ----------------------------------------------------------- | |
| 227 | − | ||
| 228 | − | const ICON: Record<AttentionKind, ReactNode> = { | |
| 229 | − | limit: <CircleDollarSign size={16} className="text-danger" />, | |
| 230 | − | deploy: <Rocket size={16} className="text-danger" />, | |
| 231 | − | session_cap: <CircleDollarSign size={16} className="text-warn" />, | |
| 232 | − | agent_budget: <CircleDollarSign size={16} className="text-danger" />, | |
| 233 | − | pull_blocked: <GitPullRequest size={16} className="text-warn" />, | |
| 234 | − | ready: <GitMerge size={16} className="text-accent" />, | |
| 235 | − | review: <GitPullRequest size={16} className="text-info" />, | |
| 236 | − | invitation: <Mail size={16} className="text-info" />, | |
| 237 | − | agent_waiting: <Sparkles size={16} className="text-warn" />, | |
| 238 | − | stuck: <Hourglass size={16} className="text-warn" />, | |
| 239 | − | runner: <Server size={16} className="text-warn" />, | |
| 240 | − | notification: <Bell size={16} className="text-warn" />, | |
| 241 | − | chat: <AtSign size={16} className="text-accent" />, | |
| 242 | − | }; | |
| 243 | − | ||
| 244 | − | const STAKE_TONE: Record<NonNullable<Stake["tone"]> | "none", string> = { | |
| 245 | − | danger: "text-danger", | |
| 246 | − | warn: "text-warn", | |
| 247 | − | accent: "text-accent", | |
| 248 | − | none: "text-fg-soft", | |
| 249 | − | }; | |
| 250 | − | ||
| 251 | − | function StakeText({ stake }: { stake: Stake }) { | |
| 252 | − | return <span className={`text-xs font-medium tabular-nums ${STAKE_TONE[stake.tone ?? "none"]}`}>{stake.text}</span>; | |
| 253 | − | } | |
| 254 | − | ||
| 255 | − | /** Most rows Needs attention shows. */ | |
| 256 | − | const ROWS = 6; | |
| 257 | − | ||
| 258 | − | function AttentionItem({ row }: { row: AttentionRow }) { | |
| 259 | − | return ( | |
| 260 | − | <li className="flex items-start gap-3 border-t border-line px-5 py-3 first:border-t-0"> | |
| 261 | − | <span className="mt-0.5 shrink-0">{ICON[row.kind]}</span> | |
| 262 | − | <div className="min-w-0 grow"> | |
| 263 | − | <Link to={row.action.to} className="block truncate text-sm font-semibold text-fg hover:underline"> | |
| 264 | − | {row.title} | |
| 265 | − | </Link> | |
| 266 | − | <p className="truncate text-xs text-muted">{row.detail}</p> | |
| 267 | − | {row.stake && ( | |
| 268 | − | <div className="mt-1 sm:hidden"> | |
| 269 | − | <StakeText stake={row.stake} /> | |
| 270 | − | </div> | |
| 271 | − | )} | |
| 272 | − | </div> | |
| 273 | − | <div className="hidden max-w-48 shrink-0 pt-0.5 text-right whitespace-nowrap sm:block">{row.stake && <StakeText stake={row.stake} />}</div> | |
| 274 | − | <Link | |
| 275 | − | to={row.action.to} | |
| 276 | − | className="inline-flex h-7 shrink-0 items-center rounded-md border border-line-strong px-2.5 text-xs font-medium text-fg transition-colors hover:border-accent/50 hover:bg-raised" | |
| 277 | − | > | |
| 278 | − | {row.action.label} | |
| 279 | − | </Link> | |
| 280 | − | </li> | |
| 281 | − | ); | |
| 282 | − | } | |
| 283 | − | ||
| 284 | − | export function AttentionList({ attention }: { attention: Attention }) { | |
| 285 | − | const { rows, missing } = attention; | |
| 286 | − | return ( | |
| 287 | − | <> | |
| 288 | − | {rows.length === 0 ? ( | |
| 289 | − | <Quiet>{missing.length > 0 ? "Nothing is waiting on you in what could be read." : "Nothing is waiting on you."}</Quiet> | |
| 290 | − | ) : ( | |
| 291 | − | <ul> | |
| 292 | − | {rows.slice(0, ROWS).map((row) => ( | |
| 293 | − | <AttentionItem key={row.key} row={row} /> | |
| 294 | − | ))} | |
| 295 | − | </ul> | |
| 296 | − | )} | |
| 297 | − | {rows.length > ROWS && ( | |
| 298 | − | <p className="border-t border-line px-5 py-2.5 text-xs text-muted"> | |
| 299 | − | {(rows.length - ROWS).toLocaleString("en-US")} more in{" "} | |
| 300 | − | <Link to="/notifications" className="text-fg-soft underline-offset-2 hover:underline"> | |
| 301 | − | Notifications | |
| 302 | − | </Link> | |
| 303 | − | , Chat and Code. | |
| 304 | − | </p> | |
| 305 | − | )} | |
| 306 | − | {missing.length > 0 && ( | |
| 307 | − | <p className="flex items-center gap-1.5 border-t border-line px-5 py-2.5 text-xs text-faint"> | |
| 308 | − | <AlertTriangle size={12} className="shrink-0" /> | |
| 309 | − | {listOf(missing)} didn't answer, so what waits there isn't listed. | |
| 310 | − | </p> | |
| 311 | − | )} | |
| 312 | − | </> | |
| 313 | − | ); | |
| 314 | − | } | |
| 315 | − | ||
| 316 | − | const listOf = (names: string[]) => (names.length <= 1 ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`); | |
| 317 | − | ||
| 318 | − | export function Sources({ work }: { work: DayWork | null }) { | |
| 319 | − | return ( | |
| 320 | − | <div className="border-t border-line px-5 py-4"> | |
| 321 | − | <h3 className="text-xs font-semibold text-fg-soft">Where today's work came from</h3> | |
| 322 | − | {!work ? ( | |
| 323 | − | <p className="mt-2 text-xs text-muted">Couldn't be read right now.</p> | |
| 324 | − | ) : work.sources.length === 0 ? ( | |
| 325 | − | <p className="mt-2 text-xs text-muted">No agent work today yet.</p> | |
| 326 | − | ) : ( | |
| 327 | − | <ul className="mt-2.5 flex flex-wrap gap-2"> | |
| 328 | − | {work.sources.map(({ source, count }) => ( | |
| 329 | − | <li key={source} className="inline-flex items-center gap-2 rounded-lg border border-line bg-raised/40 px-2.5 py-1.5 text-xs"> | |
| 330 | − | <span className="text-muted">{SOURCE_LABEL[source]}</span> | |
| 331 | − | <span className="font-semibold text-fg tabular-nums">{count.toLocaleString("en-US")}</span> | |
| 332 | − | </li> | |
| 333 | − | ))} | |
| 334 | − | </ul> | |
| 335 | − | )} | |
| 336 | − | </div> | |
| 337 | − | ); | |
| 338 | − | } | |
| 339 | − | ||
| 340 | − | export function RowsSkeleton({ rows = 3 }: { rows?: number }) { | |
| 341 | − | return ( | |
| 342 | − | <div className="space-y-4 px-5 pb-5" aria-busy="true"> | |
| 343 | − | {Array.from({ length: rows }, (_, i) => ( | |
| 344 | − | <div key={i} className="flex items-center gap-3"> | |
| 345 | − | <Skeleton className="size-4 rounded" /> | |
| 346 | − | <div className="grow space-y-1.5"> | |
| 347 | − | <Skeleton className="h-3.5" style={{ width: `${60 - i * 8}%` }} /> | |
| 348 | − | <Skeleton className="h-3" style={{ width: `${80 - i * 10}%` }} /> | |
| 349 | − | </div> | |
| 350 | − | <Skeleton className="h-7 w-16" /> | |
| 351 | − | </div> | |
| 352 | − | ))} | |
| 353 | − | </div> | |
| 354 | − | ); | |
| 355 | − | } | |
| 356 | − | ||
| 357 | − | // --- Spent today -------------------------------------------------------------- | |
| 358 | − | ||
| 359 | − | /** | |
| 360 | − | * Each kind of charge keeps its colour, whatever its rank: models one, | |
| 361 | − | * sandboxes and runners another, hosting a third, storage and the rest grey. | |
| 362 | − | */ | |
| 363 | − | function kindColour(kind: string): string { | |
| 364 | − | if (/agent|model|gateway/i.test(kind)) return "bg-accent"; | |
| 365 | − | if (/sandbox|runner/i.test(kind)) return "bg-info"; | |
| 366 | − | if (/deploy|domain/i.test(kind)) return "bg-fg-soft"; | |
| 367 | − | if (/storage|git|cache|package/i.test(kind)) return "bg-muted"; | |
| 368 | − | return "bg-faint"; | |
| 369 | − | } | |
| 370 | − | ||
| 371 | − | export function SpendCard({ spend, slug }: { spend: Spend | null; slug: string }) { | |
| 372 | − | return ( | |
| 373 | − | <Card title="Spent today" link={{ label: "Billing", to: `/${slug}/-/billing` }}> | |
| 374 | − | {!spend ? ( | |
| 375 | − | <Quiet>Billing didn't answer, so today's spend can't be shown right now.</Quiet> | |
| 376 | − | ) : ( | |
| 377 | − | <div className="px-5 pb-5"> | |
| 378 | − | <div className="text-3xl font-semibold tracking-tight text-fg tabular-nums">{dollars(spend.totalMicros)}</div> | |
| 379 | − | <p className="mt-1 text-xs text-muted">{dollars(spend.monthMicros)} this month so far</p> | |
| 380 | − | {spend.lines.length === 0 ? ( | |
| 381 | − | <p className="mt-4 text-sm text-muted">Nothing on today's statement yet.</p> | |
| 382 | − | ) : ( | |
| 383 | − | <> | |
| 384 | − | <div className="mt-4 flex h-2.5 gap-[2px] overflow-hidden rounded-full" role="img" aria-label="Today's spend by kind of charge"> | |
| 385 | − | {spend.lines.map((line) => ( | |
| 386 | − | <span key={line.kind} className={`h-full min-w-[3px] ${kindColour(line.kind)}`} style={{ flexGrow: line.micros }} /> | |
| 387 | − | ))} | |
| 388 | − | </div> | |
| 389 | − | <ul className="mt-3 space-y-2"> | |
| 390 | − | {spend.lines.map((line) => ( | |
| 391 | − | <li key={line.kind} className="flex items-center gap-2 text-sm"> | |
| 392 | − | <span className={`size-2 shrink-0 rounded-[2px] ${kindColour(line.kind)}`} aria-hidden /> | |
| 393 | − | <span className="min-w-0 grow truncate text-fg-soft">{line.kind}</span> | |
| 394 | − | <span className="text-fg tabular-nums">{dollars(line.micros)}</span> | |
| 395 | − | </li> | |
| 396 | − | ))} | |
| 397 | − | </ul> | |
| 398 | − | </> | |
| 399 | − | )} | |
| 400 | − | <p className="mt-4 text-[0.6875rem] leading-relaxed text-faint">At price, as your statement shows it. Statement days run midnight to midnight UTC.</p> | |
| 401 | − | </div> | |
| 402 | − | )} | |
| 403 | − | </Card> | |
| 404 | − | ); | |
| 405 | − | } | |
| 406 | − | ||
| 407 | − | export function SpendSkeleton() { | |
| 408 | − | return ( | |
| 409 | − | <div className="space-y-3 px-5 pb-5" aria-busy="true"> | |
| 410 | − | <Skeleton className="h-8 w-28" /> | |
| 411 | − | <Skeleton className="h-2.5 w-full rounded-full" /> | |
| 412 | − | <Skeleton className="h-3 w-3/4" /> | |
| 413 | − | <Skeleton className="h-3 w-2/3" /> | |
| 414 | − | </div> | |
| 415 | − | ); | |
| 416 | − | } | |
| 417 | − | ||
| 418 | − | // --- Start here --------------------------------------------------------------- | |
| 419 | − | ||
| 420 | − | export function StartHere({ attention, now }: { attention: Attention; now: number }) { | |
| 421 | − | const start = attention.start; | |
| 422 | − | return ( | |
| 423 | − | <Card | |
| 424 | − | title={ | |
| 425 | − | <> | |
| 426 | − | Start here <span className="font-normal text-faint">· picked by g1t</span> | |
| 427 | − | </> | |
| 428 | − | } | |
| 429 | − | > | |
| 430 | − | {!start ? ( | |
| 431 | − | <Quiet>{attention.missing.length > 0 ? "Nothing to pick from what could be read." : "Nothing is waiting on you, so there's nothing to pick."}</Quiet> | |
| 432 | − | ) : ( | |
| 433 | − | <div className="px-5 pb-5"> | |
| 434 | − | <div className="flex items-start gap-3"> | |
| 435 | − | <span className="mt-0.5 shrink-0">{ICON[start.row.kind]}</span> | |
| 436 | − | <div className="min-w-0"> | |
| 437 | − | <p className="text-[0.9375rem] leading-snug font-semibold text-fg">{start.row.title}</p> | |
| 438 | − | <p className="mt-1 line-clamp-2 text-xs text-muted">{start.row.detail}</p> | |
| 439 | − | </div> | |
| 440 | − | </div> | |
| 441 | − | <dl className="mt-4 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs"> | |
| 442 | − | {start.row.owner && ( | |
| 443 | − | <> | |
| 444 | − | <dt className="text-muted">Owner</dt> | |
| 445 | − | <dd className="flex min-w-0 items-center gap-1.5 text-fg-soft"> | |
| 446 | − | {start.row.owner.agent ? <Bot size={12} className="shrink-0 text-accent" /> : <User size={12} className="shrink-0 text-muted" />} | |
| 447 | − | <span className="truncate">{start.row.owner.agent ? `@${start.row.owner.name}` : start.row.owner.name}</span> | |
| 448 | − | </dd> | |
| 449 | − | </> | |
| 450 | − | )} | |
| 451 | − | <dt className="text-muted">Next</dt> | |
| 452 | − | <dd className="text-fg-soft">{start.row.action.label}</dd> | |
| 453 | − | {start.row.at > 0 && ( | |
| 454 | − | <> | |
| 455 | − | <dt className="text-muted">Waiting</dt> | |
| 456 | − | <dd className="text-fg-soft" suppressHydrationWarning> | |
| 457 | − | {waited(start.row.at, now)} | |
| 458 | − | </dd> | |
| 459 | − | </> | |
| 460 | − | )} | |
| 461 | − | {start.row.stake && ( | |
| 462 | − | <> | |
| 463 | − | <dt className="text-muted">At stake</dt> | |
| 464 | − | <dd> | |
| 465 | − | <StakeText stake={start.row.stake} /> | |
| 466 | − | </dd> | |
| 467 | − | </> | |
| 468 | − | )} | |
| 469 | − | </dl> | |
| 470 | − | <p className="mt-4 rounded-lg bg-raised/60 px-3 py-2.5 text-xs leading-relaxed text-fg-soft"> | |
| 471 | − | <span className="font-medium text-fg">Why this one: </span> | |
| 472 | − | {start.why} | |
| 473 | − | </p> | |
| 474 | − | <Link | |
| 475 | − | to={start.row.action.to} | |
| 476 | − | className="mt-4 inline-flex h-8 items-center gap-1.5 rounded-lg bg-accent px-3 text-sm font-medium text-bg transition-colors hover:bg-accent-hover" | |
| 477 | − | > | |
| 478 | − | {start.row.action.label} | |
| 479 | − | <ArrowRight size={14} /> | |
| 480 | − | </Link> | |
| 481 | − | </div> | |
| 482 | − | )} | |
| 483 | − | </Card> | |
| 484 | − | ); | |
| 485 | − | } |
| 2 | 2 | * The dock's built-in apps, the apps a workspace installed from the | |
| 3 | 3 | * Marketplace, and the ones each person pins to their dock. | |
| 4 | 4 | * | |
| 5 | − | * Built-in apps (Today, Chat, Notifications, Agents, Code, Artifacts, | |
| 5 | + | * Built-in apps (Home, Chat, Notifications, Agents, Code, Artifacts, | |
| 6 | 6 | * People and Workspace) are always in the dock; their own pages (Projects, | |
| 7 | 7 | * Usage, Teams and the rest) are in their sidebars. Apps are what the | |
| 8 | 8 | * workspace added from the Marketplace: today, the integrations it | |
| ⋯ | |||
| 26 | 26 | import { CONNECTOR_PUBLISHER, extensionById } from "@g1t/contracts/marketplace"; | |
| 27 | 27 | ||
| 28 | 28 | /** The apps that are always in the dock. */ | |
| 29 | − | export type BuiltinApp = "today" | "chat" | "notifications" | "agents" | "code" | "artifacts" | "people" | "workspace"; | |
| 29 | + | export type BuiltinApp = "home" | "chat" | "notifications" | "agents" | "code" | "artifacts" | "people" | "workspace"; | |
| 30 | 30 | ||
| 31 | 31 | export type BuiltinInfo = { | |
| 32 | 32 | key: BuiltinApp; | |
| ⋯ | |||
| 41 | 41 | ||
| 42 | 42 | /** The dock's built-in apps, in its order. */ | |
| 43 | 43 | export const BUILTINS: BuiltinInfo[] = [ | |
| 44 | − | { key: "today", name: "Today", path: under("today") }, | |
| 44 | + | { key: "home", name: "Home", path: under("home") }, | |
| 45 | 45 | { key: "chat", name: "Chat", path: under("chat") }, | |
| 46 | 46 | { key: "notifications", name: "Notifications", path: () => "/notifications" }, | |
| 47 | 47 | { key: "agents", name: "Agents", path: under("agents") }, | |
| 35 | 35 | }); | |
| 36 | 36 | ||
| 37 | 37 | test("someone signed in gets the app everywhere else, public pages included", () => { | |
| 38 | − | for (const path of ["/", "/explore", "/u/ada", "/other/web", "/acme/-/today", "/notifications", "/pricing", "/does/not/exist"]) { | |
| 38 | + | for (const path of ["/", "/explore", "/u/ada", "/other/web", "/acme/-/home", "/notifications", "/pricing", "/does/not/exist"]) { | |
| 39 | 39 | assert.equal(frameOf(path, member), "app", path); | |
| 40 | 40 | assert.equal(frameOf(path, newcomer), "app", path); | |
| 41 | 41 | } |
| 1 | + | /** | |
| 2 | + | * Home's reads: each section's data from its own service, every call on | |
| 3 | + | * its own so one that fails leaves only its section saying so. The shaping | |
| 4 | + | * is in ./home.ts. | |
| 5 | + | */ | |
| 6 | + | import { env } from "cloudflare:workers"; | |
| 7 | + | ||
| 8 | + | import { type AgentSession, type AgentsOverview, type Deployment, type InstallRequests, type Memory, type ProjectDeploys, type Repo, type User, notifyClient } from "@g1t/contracts"; | |
| 9 | + | ||
| 10 | + | import { type CodePull, type CodeWork, type SessionWork, type Span, type Spend, pullKey, spanMonths, spendIn } from "./home"; | |
| 11 | + | import { agents, billing, deployments, repos, work, workspaceAgents } from "./services.server"; | |
| 12 | + | ||
| 13 | + | /** Pull requests read per project and list: `LIST_PAGE` in services/work. */ | |
| 14 | + | const PULL_PAGE = 100; | |
| 15 | + | /** The most projects whose pull requests are read. */ | |
| 16 | + | const MAX_PROJECTS = 50; | |
| 17 | + | /** The most projects whose builds are read for the span. */ | |
| 18 | + | const MAX_DEPLOY_PROJECTS = 20; | |
| 19 | + | /** Revise runs and sessions read: the services' most in one list. */ | |
| 20 | + | const RUN_PAGE = 200; | |
| 21 | + | const SESSION_PAGE = 200; | |
| 22 | + | const DAY = 24 * 60 * 60 * 1000; | |
| 23 | + | ||
| 24 | + | const warn = (what: string) => (error: unknown) => { | |
| 25 | + | console.warn(`home: ${what} failed`, error); | |
| 26 | + | return null; | |
| 27 | + | }; | |
| 28 | + | ||
| 29 | + | /** | |
| 30 | + | * When the viewer was last on this workspace's Home, from notify, where it | |
| 31 | + | * is kept with them (the same on every device). Null when there is no | |
| 32 | + | * visit on record; undefined when notify did not answer. | |
| 33 | + | */ | |
| 34 | + | export async function loadLastVisit(viewer: User, slug: string): Promise<number | null | undefined> { | |
| 35 | + | if (!env.NOTIFY) return undefined; | |
| 36 | + | const visit = await notifyClient(env.NOTIFY).lastVisit(viewer, slug).catch(warn("last visit")); | |
| 37 | + | if (!visit) return undefined; | |
| 38 | + | return visit.seen_at ? Date.parse(visit.seen_at) : null; | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /** Marks the viewer's visit at `at`, the time the page they looked at loaded. */ | |
| 42 | + | export async function markVisit(viewer: User, slug: string, at: number): Promise<boolean> { | |
| 43 | + | if (!env.NOTIFY) return false; | |
| 44 | + | const marked = await notifyClient(env.NOTIFY).markVisit(viewer, slug, new Date(at).toISOString()).catch(warn("mark visit")); | |
| 45 | + | return marked != null; | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /** | |
| 49 | + | * The workspace's projects' pull requests, open and recently merged or | |
| 50 | + | * closed, any author's, with how many times each agent's was sent back to | |
| 51 | + | * revise. `complete` when every list reaches back to `reach`. Null when | |
| 52 | + | * Code did not answer. | |
| 53 | + | */ | |
| 54 | + | export async function loadCodeWork(viewer: User, slug: string, reach: number): Promise<CodeWork | null> { | |
| 55 | + | const [list, runs] = await Promise.all([ | |
| 56 | + | repos.list(viewer, { namespace: slug }).catch(warn("repos")), | |
| 57 | + | agents.listRuns(viewer, { workspace: slug, kind: "revise", limit: RUN_PAGE }).catch(warn("revise runs")), | |
| 58 | + | ]); | |
| 59 | + | if (!list || !runs?.ok) return null; | |
| 60 | + | const owned = list.filter((repo) => !repo.forkOf && repo.namespace.toLowerCase() === slug); | |
| 61 | + | const chosen = owned.slice(0, MAX_PROJECTS); | |
| 62 | + | const byId = new Map<string, Repo>(chosen.map((repo) => [repo.id, repo])); | |
| 63 | + | const batch = chosen.length > 0 ? await work.pullsForRepos([...byId.keys()], viewer, PULL_PAGE).catch(warn("pull requests")) : []; | |
| 64 | + | if (!batch) return null; | |
| 65 | + | const pulls: CodePull[] = []; | |
| 66 | + | let complete = owned.length <= MAX_PROJECTS; | |
| 67 | + | for (const entry of batch) { | |
| 68 | + | const repo = byId.get(entry.repoId); | |
| 69 | + | if (!repo) continue; | |
| 70 | + | const path = { namespace: repo.namespace, name: repo.name }; | |
| 71 | + | for (const pull of [...entry.open, ...entry.closed]) { | |
| 72 | + | pulls.push({ | |
| 73 | + | repo: path, | |
| 74 | + | number: pull.number, | |
| 75 | + | title: pull.title, | |
| 76 | + | status: pull.status, | |
| 77 | + | mergedAt: pull.mergedAt, | |
| 78 | + | mergedBy: pull.mergedBy, | |
| 79 | + | createdAt: pull.createdAt, | |
| 80 | + | updatedAt: pull.updatedAt, | |
| 81 | + | author: { username: pull.author.username, kind: pull.author.kind }, | |
| 82 | + | }); | |
| 83 | + | } | |
| 84 | + | // A full page that does not reach back far enough may leave some out. | |
| 85 | + | for (const list of [entry.open, entry.closed]) { | |
| 86 | + | if (list.length >= PULL_PAGE) { | |
| 87 | + | const oldest = Math.min(...list.map((pull) => Date.parse(pull.mergedAt ?? pull.updatedAt))); | |
| 88 | + | if (oldest > reach) complete = false; | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | const revisions: Record<string, number> = {}; | |
| 93 | + | for (const run of runs.value) { | |
| 94 | + | if (run.number == null) continue; | |
| 95 | + | const key = pullKey(run.repo, run.number); | |
| 96 | + | revisions[key] = (revisions[key] ?? 0) + 1; | |
| 97 | + | } | |
| 98 | + | if (runs.value.length >= RUN_PAGE && Date.parse(runs.value[runs.value.length - 1].createdAt) > reach) complete = false; | |
| 99 | + | return { pulls, revisions, complete }; | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | /** The workspace's newest sessions; `complete` when the list reaches back before `from`. */ | |
| 103 | + | export async function loadSessions(viewer: User, slug: string, from: number): Promise<SessionWork | null> { | |
| 104 | + | const listed = await workspaceAgents.sessions(slug, viewer, { limit: SESSION_PAGE }).catch(warn("sessions")); | |
| 105 | + | if (!listed?.ok) return null; | |
| 106 | + | const sessions: AgentSession[] = listed.value; | |
| 107 | + | const oldest = sessions.at(-1); | |
| 108 | + | const complete = sessions.length < SESSION_PAGE || (oldest != null && Date.parse(oldest.created_at) < from); | |
| 109 | + | return { sessions, complete }; | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /** The agents overview: sessions waiting on the viewer, live ones, and the workspace's budget alert. */ | |
| 113 | + | export async function loadAgentsOverview(viewer: User, slug: string): Promise<AgentsOverview | null> { | |
| 114 | + | const overview = await workspaceAgents.overview(slug, viewer).catch(warn("agents overview")); | |
| 115 | + | return overview?.ok ? overview.value : null; | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /** Install requests as the viewer sees them; only an owner's (`can_resolve`) are anyone else's. */ | |
| 119 | + | export async function loadInstallRequests(viewer: User, slug: string): Promise<InstallRequests | null> { | |
| 120 | + | const requests = await workspaceAgents.installRequests(slug, viewer).catch(warn("install requests")); | |
| 121 | + | return requests?.ok ? requests.value : null; | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /** The workspace's memory, for the decisions recorded in it. */ | |
| 125 | + | export async function loadMemories(viewer: User, slug: string): Promise<Memory[] | null> { | |
| 126 | + | const memories = await agents.listMemories(viewer, slug, null).catch(warn("memories")); | |
| 127 | + | return memories?.ok ? memories.value.workspace : null; | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | /** | |
| 131 | + | * Every project's builds at a glance, and the builds of the projects that | |
| 132 | + | * deploy, for the span: the projects whose newest build is older than the | |
| 133 | + | * span are not read again. | |
| 134 | + | */ | |
| 135 | + | export async function loadDeploys( | |
| 136 | + | viewer: User, | |
| 137 | + | slug: string, | |
| 138 | + | from: number, | |
| 139 | + | ): Promise<{ overview: ProjectDeploys[]; projects: { slug: string; deployments: Deployment[] }[]; complete: boolean } | null> { | |
| 140 | + | const overview = await deployments.overview(slug, viewer).catch(warn("deploys overview")); | |
| 141 | + | if (!overview?.ok) return null; | |
| 142 | + | const recent = overview.value.filter((project) => project.latest && Date.parse(project.latest.finishedAt ?? project.latest.createdAt) >= from); | |
| 143 | + | const read = recent.slice(0, MAX_DEPLOY_PROJECTS); | |
| 144 | + | const lists = await Promise.all( | |
| 145 | + | read.map((project) => | |
| 146 | + | deployments | |
| 147 | + | .list({ workspace: slug, slug: project.slug }, viewer) | |
| 148 | + | .then((result) => (result.ok ? { slug: project.slug, deployments: result.value.deployments } : null)) | |
| 149 | + | .catch(warn(`deploys of ${project.slug}`)), | |
| 150 | + | ), | |
| 151 | + | ); | |
| 152 | + | const projects = lists.filter((entry) => entry != null); | |
| 153 | + | return { overview: overview.value, projects, complete: projects.length === recent.length }; | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | /** The span's spend from the statements of the months it touches. Null when billing did not answer. */ | |
| 157 | + | export async function loadSpend(viewer: User, slug: string, span: Pick<Span, "from" | "now">): Promise<Spend | null> { | |
| 158 | + | const months = spanMonths(span); | |
| 159 | + | const statements = await Promise.all(months.map((month) => billing.statement(slug, viewer, month, "day").catch(warn(`statement ${month}`)))); | |
| 160 | + | if (statements.some((statement) => !statement?.ok)) return null; | |
| 161 | + | return spendIn( | |
| 162 | + | statements.map((statement) => (statement as Extract<typeof statement, { ok: true }>).value), | |
| 163 | + | span, | |
| 164 | + | ); | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /** How far back Code is read: 7 days before the span, for the comparison. */ | |
| 168 | + | export function reachFor(span: Pick<Span, "from">): number { | |
| 169 | + | return span.from - 7 * DAY; | |
| 170 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import type { AgentSession, Deployment, InboxItem, InstallRequest, Memory } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import { | |
| 7 | + | type AttentionRow, | |
| 8 | + | type CodeNeed, | |
| 9 | + | type CodePull, | |
| 10 | + | acceptance, | |
| 11 | + | agentRow, | |
| 12 | + | attention, | |
| 13 | + | chatRow, | |
| 14 | + | codeRow, | |
| 15 | + | dayIn, | |
| 16 | + | decisionsIn, | |
| 17 | + | deploysIn, | |
| 18 | + | duration, | |
| 19 | + | installRequestRow, | |
| 20 | + | landedIn, | |
| 21 | + | limitRow, | |
| 22 | + | notificationRow, | |
| 23 | + | parseWindow, | |
| 24 | + | pullOutcome, | |
| 25 | + | rankAttention, | |
| 26 | + | running, | |
| 27 | + | runningDeploys, | |
| 28 | + | runningSessions, | |
| 29 | + | sessionOutcome, | |
| 30 | + | sinceWords, | |
| 31 | + | spanFor, | |
| 32 | + | spanMonths, | |
| 33 | + | spanSentence, | |
| 34 | + | spanWork, | |
| 35 | + | spendIn, | |
| 36 | + | startHere, | |
| 37 | + | trendLabel, | |
| 38 | + | waitingSentence, | |
| 39 | + | } from "./home.ts"; | |
| 40 | + | ||
| 41 | + | const HOUR = 3_600_000; | |
| 42 | + | const DAY = 24 * HOUR; | |
| 43 | + | // Friday 2026-10-09, 18:00 UTC; 11:00 in Los Angeles. | |
| 44 | + | const NOW = Date.parse("2026-10-09T18:00:00Z"); | |
| 45 | + | const LA = "America/Los_Angeles"; | |
| 46 | + | const repo = { namespace: "acme", name: "web" }; | |
| 47 | + | const iso = (ms: number) => new Date(ms).toISOString(); | |
| 48 | + | ||
| 49 | + | function pull(over: Partial<CodePull> = {}): CodePull { | |
| 50 | + | return { | |
| 51 | + | repo, | |
| 52 | + | number: 1, | |
| 53 | + | title: "Fix the flaky test", | |
| 54 | + | status: "merged", | |
| 55 | + | mergedAt: iso(NOW - HOUR), | |
| 56 | + | createdAt: iso(NOW - 3 * HOUR), | |
| 57 | + | updatedAt: iso(NOW - HOUR), | |
| 58 | + | author: { username: "g1t", kind: "agent" }, | |
| 59 | + | ...over, | |
| 60 | + | }; | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | function session(over: Partial<AgentSession> = {}): AgentSession { | |
| 64 | + | return { | |
| 65 | + | id: "s1", | |
| 66 | + | workspace_id: "w", | |
| 67 | + | agent_id: "a", | |
| 68 | + | agent_handle: "margo", | |
| 69 | + | agent_name: "Margo", | |
| 70 | + | agent_avatar_seed: "margo", | |
| 71 | + | subagent: null, | |
| 72 | + | kind: "chat", | |
| 73 | + | parent_id: null, | |
| 74 | + | root_id: "s1", | |
| 75 | + | payer_agent_id: "a", | |
| 76 | + | title: "Draft the launch notes", | |
| 77 | + | goal: "", | |
| 78 | + | status: "done", | |
| 79 | + | status_note: null, | |
| 80 | + | summary: null, | |
| 81 | + | channel_id: "c", | |
| 82 | + | channel_kind: "channel", | |
| 83 | + | channel_name: "launch", | |
| 84 | + | card_message_id: null, | |
| 85 | + | asked_by: null, | |
| 86 | + | asked_by_username: "bo", | |
| 87 | + | routine_id: null, | |
| 88 | + | steps: 3, | |
| 89 | + | tool_calls: 2, | |
| 90 | + | input_tokens: 0, | |
| 91 | + | output_tokens: 0, | |
| 92 | + | charged_micros: 400_000, | |
| 93 | + | cap_micros: 2_000_000, | |
| 94 | + | model: null, | |
| 95 | + | outputs: [], | |
| 96 | + | created_at: iso(NOW - 2 * HOUR), | |
| 97 | + | updated_at: iso(NOW - HOUR), | |
| 98 | + | finished_at: iso(NOW - HOUR), | |
| 99 | + | visible: true, | |
| 100 | + | ...over, | |
| 101 | + | }; | |
| 102 | + | } | |
| 103 | + | const need = (over: Partial<CodeNeed>): CodeNeed => ({ | |
| 104 | + | key: "pull:p1", | |
| 105 | + | reason: "ready_to_merge", | |
| 106 | + | repo, | |
| 107 | + | ref: "#12", | |
| 108 | + | title: "Add retries", | |
| 109 | + | ask: "Waiting for you to merge it.", | |
| 110 | + | by: { name: "g1t", agent: true }, | |
| 111 | + | for: "bo", | |
| 112 | + | at: NOW - 2 * HOUR, | |
| 113 | + | to: "/acme/web/pull/12", | |
| 114 | + | facts: [{ label: "Files changed", value: "4", tone: null }], | |
| 115 | + | ...over, | |
| 116 | + | }); | |
| 117 | + | ||
| 118 | + | test("Code's needs become rows with what is at stake", () => { | |
| 119 | + | const ready = codeRow(need({})); | |
| 120 | + | assert.equal(ready.kind, "ready"); | |
| 121 | + | assert.equal(ready.stake?.text, "4 files to land"); | |
| 122 | + | assert.equal(ready.action.label, "Merge"); | |
| 123 | + | const deploy = codeRow(need({ key: "deploy:site", reason: "blocking", facts: [{ label: "Production", value: "Serving the build before", tone: "good" }] })); | |
| 124 | + | assert.equal(deploy.kind, "deploy"); | |
| 125 | + | assert.equal(deploy.stake?.text, "Production on an older build"); | |
| 126 | + | assert.equal(codeRow(need({ reason: "checks_failing" })).kind, "pull_blocked"); | |
| 127 | + | assert.equal(codeRow(need({ key: "run:r1", reason: "stalled", facts: [{ label: "Cost so far", value: "$1.20", tone: null }] })).stake?.text, "$1.20 so far"); | |
| 128 | + | }); | |
| 129 | + | ||
| 130 | + | const row = (over: Partial<AttentionRow>): AttentionRow => ({ | |
| 131 | + | key: "k", | |
| 132 | + | kind: "chat", | |
| 133 | + | title: "t", | |
| 134 | + | detail: "d", | |
| 135 | + | stake: null, | |
| 136 | + | action: { label: "Open", to: "/x" }, | |
| 137 | + | owner: null, | |
| 138 | + | at: NOW, | |
| 139 | + | from: "chat", | |
| 140 | + | ...over, | |
| 141 | + | }); | |
| 142 | + | ||
| 143 | + | test("Start here: the most pressing tier first, then what has waited longest", () => { | |
| 144 | + | const rows = [ | |
| 145 | + | row({ key: "chat:old", kind: "chat", at: NOW - 5 * DAY, action: { label: "Reply", to: "/acme/-/chat/general" } }), | |
| 146 | + | row({ key: "ready:new", kind: "ready", at: NOW - HOUR, action: { label: "Merge", to: "/acme/web/pull/2" } }), | |
| 147 | + | row({ key: "ready:old", kind: "ready", at: NOW - 3 * HOUR, action: { label: "Merge", to: "/acme/web/pull/1" } }), | |
| 148 | + | row({ key: "session:s", kind: "session_cap", at: NOW - 10 * 60_000, action: { label: "Approve more", to: "/acme/-/agents/margo/sessions/s" } }), | |
| 149 | + | ]; | |
| 150 | + | const ranked = rankAttention(rows); | |
| 151 | + | assert.deepEqual(ranked.map((r) => r.key), ["session:s", "ready:old", "ready:new", "chat:old"]); | |
| 152 | + | const start = startHere(ranked); | |
| 153 | + | assert.equal(start?.row.key, "session:s"); | |
| 154 | + | assert.match(start?.why ?? "", /spend cap/); | |
| 155 | + | // A failed production deploy beats everything. | |
| 156 | + | const withDeploy = rankAttention([...rows, row({ key: "deploy:site", kind: "deploy", at: NOW, action: { label: "See the build", to: "/acme/site/deployments/1" } })]); | |
| 157 | + | assert.equal(startHere(withDeploy)?.row.key, "deploy:site"); | |
| 158 | + | // Ties in tier say so. | |
| 159 | + | const two = startHere(rankAttention(rows.filter((r) => r.kind === "ready"))); | |
| 160 | + | assert.equal(two?.row.key, "ready:old"); | |
| 161 | + | assert.match(two?.why ?? "", /Of the 2 like it, it has waited longest/); | |
| 162 | + | assert.equal(startHere([]), null); | |
| 163 | + | }); | |
| 164 | + | ||
| 165 | + | test("the same order whatever order the rows came in", () => { | |
| 166 | + | const rows = [row({ key: "b", kind: "review", at: NOW }), row({ key: "a", kind: "review", at: NOW, action: { label: "Review", to: "/y" } })]; | |
| 167 | + | assert.deepEqual(rankAttention(rows).map((r) => r.key), ["a", "b"]); | |
| 168 | + | assert.deepEqual(rankAttention([...rows].reverse()).map((r) => r.key), ["a", "b"]); | |
| 169 | + | }); | |
| 170 | + | ||
| 171 | + | const item = (over: Partial<InboxItem>): InboxItem => ({ | |
| 172 | + | id: "n1", | |
| 173 | + | reason: "ci_activity", | |
| 174 | + | severity: "error", | |
| 175 | + | title: "Checks failed on #12", | |
| 176 | + | body: "Add retries", | |
| 177 | + | event: "check.failed", | |
| 178 | + | repo: "acme/web", | |
| 179 | + | workspace: "acme", | |
| 180 | + | subject: "pull", | |
| 181 | + | number: 12, | |
| 182 | + | url: "/acme/web/pull/12", | |
| 183 | + | actor: "g1t", | |
| 184 | + | count: 1, | |
| 185 | + | createdAt: iso(NOW - HOUR), | |
| 186 | + | updatedAt: iso(NOW - HOUR), | |
| 187 | + | readAt: null, | |
| 188 | + | doneAt: null, | |
| 189 | + | saved: false, | |
| 190 | + | snoozedUntil: null, | |
| 191 | + | ...over, | |
| 192 | + | }); | |
| 193 | + | ||
| 194 | + | test("notifications: warnings and failures in this workspace; none about repositories without Code", () => { | |
| 195 | + | assert.equal(notificationRow(item({}), "acme", true)?.kind, "notification"); | |
| 196 | + | assert.equal(notificationRow(item({}), "acme", false), null); | |
| 197 | + | assert.equal(notificationRow(item({ repo: null, url: "/acme/-/agents/margo" }), "acme", false)?.kind, "notification"); | |
| 198 | + | assert.equal(notificationRow(item({ severity: "info" }), "acme", true), null); | |
| 199 | + | assert.equal(notificationRow(item({ workspace: "other", repo: "other/web" }), "acme", true), null); | |
| 200 | + | assert.equal(notificationRow(item({ readAt: iso(NOW) }), "acme", true), null); | |
| 201 | + | }); | |
| 202 | + | ||
| 203 | + | test("a notification about something already listed is left out", () => { | |
| 204 | + | const out = attention({ | |
| 205 | + | slug: "acme", | |
| 206 | + | code: true, | |
| 207 | + | codeNeeds: [need({})], | |
| 208 | + | capped: [], | |
| 209 | + | agents: [], | |
| 210 | + | canManage: false, | |
| 211 | + | notifications: [item({})], | |
| 212 | + | chat: [], | |
| 213 | + | }); | |
| 214 | + | assert.deepEqual(out.rows.map((r) => r.key), ["pull:p1"]); | |
| 215 | + | assert.deepEqual(out.missing, []); | |
| 216 | + | }); | |
| 217 | + | ||
| 218 | + | test("without Code access no Code row is asked for or shown, and a silent source is named", () => { | |
| 219 | + | const out = attention({ | |
| 220 | + | slug: "acme", | |
| 221 | + | code: false, | |
| 222 | + | codeNeeds: [need({})], | |
| 223 | + | capped: null, | |
| 224 | + | agents: null, | |
| 225 | + | canManage: false, | |
| 226 | + | notifications: [item({}), item({ id: "n2", repo: null, reason: "agent", severity: "warning", url: "/acme/-/agents/margo" })], | |
| 227 | + | chat: null, | |
| 228 | + | }); | |
| 229 | + | assert.deepEqual(out.rows.map((r) => r.key), ["notification:n2"]); | |
| 230 | + | assert.equal(out.rows.some((r) => r.from === "code"), false); | |
| 231 | + | assert.deepEqual(out.missing, ["Agents", "Chat"]); | |
| 232 | + | }); | |
| 233 | + | ||
| 234 | + | test("an agent out of budget is listed only for those who may raise it", () => { | |
| 235 | + | const agent = { id: "a1", handle: "margo", display_name: "Margo", status: "out_of_budget", spent_month_micros: 52_000_000, budget: { monthly_micros: 50_000_000 }, updated_at: iso(NOW) }; | |
| 236 | + | assert.equal(agentRow(agent, "acme")?.stake?.text, "$0.00 left"); | |
| 237 | + | const base = { slug: "acme", code: true, codeNeeds: [], capped: [], notifications: [], chat: [] }; | |
| 238 | + | assert.equal(attention({ ...base, agents: [agent], canManage: false }).rows.length, 0); | |
| 239 | + | assert.equal(attention({ ...base, agents: [agent], canManage: true }).rows[0]?.kind, "agent_budget"); | |
| 240 | + | }); | |
| 241 | + | ||
| 242 | + | test("chat: mentions anywhere and unread direct messages, never muted", () => { | |
| 243 | + | const channel = { id: "c1", kind: "channel" as const, name: "general", last_message_at: iso(NOW) }; | |
| 244 | + | assert.equal(chatRow({ channel, title: "general", muted: false, unread: 4, mentions: 0 }, "acme"), null); | |
| 245 | + | assert.equal(chatRow({ channel, title: "general", muted: false, unread: 4, mentions: 2 }, "acme")?.stake?.text, "2 mentions"); | |
| 246 | + | assert.equal(chatRow({ channel, title: "general", muted: true, unread: 4, mentions: 2 }, "acme"), null); | |
| 247 | + | const dm = chatRow({ channel: { ...channel, kind: "dm", name: null }, title: "Bo Reed", muted: false, unread: 1, mentions: 0 }, "acme"); | |
| 248 | + | assert.equal(dm?.action.to, "/acme/-/chat/dm/c1"); | |
| 249 | + | }); | |
| 250 | + | ||
| 251 | + | // The span is NOW back to 3 days ago unless a test says otherwise. | |
| 252 | + | const SPAN = { from: NOW - 3 * DAY, now: NOW }; | |
| 253 | + | ||
| 254 | + | test("a day is the reader's day", () => { | |
| 255 | + | assert.equal(dayIn(NOW, LA), "2026-10-09"); | |
| 256 | + | assert.equal(dayIn(Date.parse("2026-10-10T02:00:00Z"), LA), "2026-10-09"); | |
| 257 | + | assert.equal(dayIn(Date.parse("2026-10-10T02:00:00Z"), null), "2026-10-10"); | |
| 258 | + | assert.equal(dayIn(NOW, "Not/AZone"), "2026-10-09"); | |
| 259 | + | }); | |
| 260 | + | ||
| 261 | + | test("since your last visit, in words, in the reader's time zone", () => { | |
| 262 | + | // NOW is Friday 11:00 in Los Angeles. | |
| 263 | + | assert.equal(sinceWords(NOW - 20_000, NOW, LA), "a moment ago"); | |
| 264 | + | assert.equal(sinceWords(NOW - 40 * 60_000, NOW, LA), "40 minutes ago"); | |
| 265 | + | assert.equal(sinceWords(NOW - HOUR - 60_000, NOW, LA), "an hour ago"); | |
| 266 | + | assert.equal(sinceWords(NOW - 2 * HOUR, NOW, LA), "2 hours ago"); | |
| 267 | + | // 07:00 Friday. | |
| 268 | + | assert.equal(sinceWords(NOW - 4 * HOUR, NOW, LA), "this morning"); | |
| 269 | + | // 02:00 Friday is Thursday night. | |
| 270 | + | assert.equal(sinceWords(NOW - 9 * HOUR, NOW, LA), "last night"); | |
| 271 | + | // 19:00 Thursday. | |
| 272 | + | assert.equal(sinceWords(NOW - 16 * HOUR, NOW, LA), "yesterday evening"); | |
| 273 | + | // 19:00 Tuesday. | |
| 274 | + | assert.equal(sinceWords(NOW - 2 * DAY - 16 * HOUR, NOW, LA), "Tuesday evening"); | |
| 275 | + | // 02:00 Wednesday is Tuesday night. | |
| 276 | + | assert.equal(sinceWords(NOW - 2 * DAY - 9 * HOUR, NOW, LA), "Tuesday night"); | |
| 277 | + | assert.equal(sinceWords(NOW - 9 * DAY, NOW, LA), "Sep 30"); | |
| 278 | + | }); | |
| 279 | + | ||
| 280 | + | test("how long a span is", () => { | |
| 281 | + | assert.equal(duration(30_000), "a moment"); | |
| 282 | + | assert.equal(duration(45 * 60_000), "45 minutes"); | |
| 283 | + | assert.equal(duration(HOUR), "1 hour"); | |
| 284 | + | assert.equal(duration(3 * DAY + HOUR), "3 days"); | |
| 285 | + | assert.equal(duration(15 * DAY), "2 weeks"); | |
| 286 | + | }); | |
| 287 | + | ||
| 288 | + | test("the span: since your last visit, capped, or a fixed window", () => { | |
| 289 | + | assert.equal(parseWindow("24h"), "24h"); | |
| 290 | + | assert.equal(parseWindow("7d"), "7d"); | |
| 291 | + | assert.equal(parseWindow("nonsense"), "last"); | |
| 292 | + | assert.equal(parseWindow(null), "last"); | |
| 293 | + | const away = spanFor("last", NOW, NOW - 2 * DAY - 16 * HOUR, LA); | |
| 294 | + | assert.equal(away.from, NOW - 2 * DAY - 16 * HOUR); | |
| 295 | + | assert.equal(away.words, "Since Tuesday evening"); | |
| 296 | + | assert.equal(away.length, "2 days"); | |
| 297 | + | assert.equal(away.note, null); | |
| 298 | + | // Never visited: the last 24 hours, and it says why. | |
| 299 | + | const first = spanFor("last", NOW, null, LA); | |
| 300 | + | assert.equal(first.from, NOW - DAY); | |
| 301 | + | assert.equal(first.note, "first"); | |
| 302 | + | assert.equal(spanFor("last", NOW, undefined, LA).note, "unknown"); | |
| 303 | + | // Away a month: 14 days at most. | |
| 304 | + | const capped = spanFor("last", NOW, NOW - 30 * DAY, LA); | |
| 305 | + | assert.equal(capped.from, NOW - 14 * DAY); | |
| 306 | + | assert.equal(capped.note, "capped"); | |
| 307 | + | assert.equal(capped.words, "In the last 14 days"); | |
| 308 | + | // The fixed windows ignore the last visit. | |
| 309 | + | assert.equal(spanFor("24h", NOW, NOW - 3 * DAY, LA).from, NOW - DAY); | |
| 310 | + | assert.deepEqual([spanFor("7d", NOW, NOW - HOUR, LA).from, spanFor("7d", NOW, NOW - HOUR, LA).words], [NOW - 7 * DAY, "In the last 7 days"]); | |
| 311 | + | }); | |
| 312 | + | ||
| 313 | + | test("an agent's pull request is accepted first time when it merged in the span with no revision", () => { | |
| 314 | + | assert.equal(pullOutcome(pull(), 0, SPAN), "accepted"); | |
| 315 | + | assert.equal(pullOutcome(pull(), 2, SPAN), "fixed"); | |
| 316 | + | assert.equal(pullOutcome(pull({ mergedAt: iso(NOW - 4 * DAY) }), 0, SPAN), null); | |
| 317 | + | assert.equal(pullOutcome(pull({ status: "closed", mergedAt: null }), 0, SPAN), "dropped"); | |
| 318 | + | // Still open is not settled: it is under Running now. | |
| 319 | + | assert.equal(pullOutcome(pull({ status: "open", mergedAt: null }), 1, SPAN), null); | |
| 320 | + | }); | |
| 321 | + | ||
| 322 | + | test("a session counts once, at the root of its tree, when it settled in the span", () => { | |
| 323 | + | assert.equal(sessionOutcome(session(), SPAN), "finished"); | |
| 324 | + | assert.equal(sessionOutcome(session({ parent_id: "s0" }), SPAN), null); | |
| 325 | + | assert.equal(sessionOutcome(session({ status: "failed" }), SPAN), "dropped"); | |
| 326 | + | assert.equal(sessionOutcome(session({ status: "stopped" }), SPAN), "dropped"); | |
| 327 | + | assert.equal(sessionOutcome(session({ status: "working", finished_at: null }), SPAN), null); | |
| 328 | + | assert.equal(sessionOutcome(session({ finished_at: iso(NOW - 4 * DAY) }), SPAN), null); | |
| 329 | + | }); | |
| 330 | + | ||
| 331 | + | test("accepted first time is accepted over every agent pull request that settled", () => { | |
| 332 | + | assert.deepEqual(acceptance(["accepted", "accepted", "fixed", "dropped", "finished"]), { value: 0.5, of: 4 }); | |
| 333 | + | assert.equal(acceptance(["finished"]), null); | |
| 334 | + | }); | |
| 335 | + | ||
| 336 | + | test("the span's work: pull requests and sessions, sources, and the 7 days before", () => { | |
| 337 | + | const work = spanWork({ | |
| 338 | + | span: SPAN, | |
| 339 | + | slug: "acme", | |
| 340 | + | code: { | |
| 341 | + | pulls: [ | |
| 342 | + | pull({ number: 1 }), | |
| 343 | + | pull({ number: 2 }), | |
| 344 | + | pull({ number: 3, status: "open", mergedAt: null }), | |
| 345 | + | // A person's pull request is not agent work. | |
| 346 | + | pull({ number: 4, author: { username: "bo", kind: "user" } }), | |
| 347 | + | // The 7 days before the span: one accepted, one fixed. | |
| 348 | + | pull({ number: 5, mergedAt: iso(NOW - 4 * DAY), updatedAt: iso(NOW - 4 * DAY) }), | |
| 349 | + | pull({ number: 6, mergedAt: iso(NOW - 5 * DAY), updatedAt: iso(NOW - 5 * DAY) }), | |
| 350 | + | // Older than that: not counted. | |
| 351 | + | pull({ number: 7, mergedAt: iso(NOW - 11 * DAY), updatedAt: iso(NOW - 11 * DAY) }), | |
| 352 | + | ], | |
| 353 | + | revisions: { "acme/web#2": 1, "acme/web#6": 2 }, | |
| 354 | + | complete: true, | |
| 355 | + | }, | |
| 356 | + | sessions: { sessions: [session(), session({ id: "s2", kind: "routine", status: "working", finished_at: null }), session({ id: "s3", parent_id: "s1" })], complete: true }, | |
| 357 | + | }); | |
| 358 | + | assert.equal(work.tasks.length, 3); | |
| 359 | + | assert.deepEqual(work.counts, { accepted: 1, fixed: 1, finished: 1, dropped: 0 }); | |
| 360 | + | assert.deepEqual(work.rate, { value: 0.5, of: 2 }); | |
| 361 | + | assert.deepEqual(work.before, { value: 0.5, of: 2 }); | |
| 362 | + | assert.deepEqual(work.sources, [ | |
| 363 | + | { source: "code", count: 2 }, | |
| 364 | + | { source: "chat", count: 1 }, | |
| 365 | + | ]); | |
| 366 | + | assert.equal(work.code, "read"); | |
| 367 | + | assert.equal(work.partial, false); | |
| 368 | + | assert.equal( | |
| 369 | + | spanSentence({ words: "Since Tuesday evening" }, work, 3), | |
| 370 | + | "Since Tuesday evening, agents finished 3 tasks and 3 changes landed. 1 needed a fix after review.", | |
| 371 | + | ); | |
| 372 | + | }); | |
| 373 | + | ||
| 374 | + | test("no trend when the 7 days before could not all be read, and none without Code", () => { | |
| 375 | + | const partial = spanWork({ span: SPAN, slug: "acme", code: { pulls: [pull()], revisions: {}, complete: false }, sessions: null }); | |
| 376 | + | assert.equal(partial.before, null); | |
| 377 | + | assert.equal(partial.partial, true); | |
| 378 | + | assert.equal(partial.sessions, "unavailable"); | |
| 379 | + | const member = spanWork({ span: SPAN, slug: "acme", code: "no_access", sessions: { sessions: [session()], complete: true } }); | |
| 380 | + | assert.equal(member.code, "no_access"); | |
| 381 | + | assert.equal(member.rate, null); | |
| 382 | + | assert.equal(member.tasks.every((t) => t.source !== "code"), true); | |
| 383 | + | }); | |
| 384 | + | ||
| 385 | + | test("the trend is in whole points", () => { | |
| 386 | + | assert.equal(trendLabel({ value: 0.89 }, { value: 0.83 }), "+6 pts vs the 7 days before"); | |
| 387 | + | assert.equal(trendLabel({ value: 0.5 }, { value: 0.75 }), "−25 pts vs the 7 days before"); | |
| 388 | + | assert.equal(trendLabel({ value: 0.5 }, { value: 0.5 }), "Same as the 7 days before"); | |
| 389 | + | assert.equal(trendLabel(null, { value: 0.5 }), null); | |
| 390 | + | }); | |
| 391 | + | ||
| 392 | + | test("the sentence for a quiet span, and for what needs you", () => { | |
| 393 | + | const quiet = spanWork({ span: SPAN, slug: "acme", code: { pulls: [], revisions: {}, complete: true }, sessions: { sessions: [], complete: true } }); | |
| 394 | + | assert.equal(spanSentence({ words: "Since an hour ago" }, quiet, 0), "Since an hour ago, nothing new has settled."); | |
| 395 | + | assert.equal(spanSentence({ words: "In the last 24 hours" }, quiet, 2), "In the last 24 hours, 2 changes landed."); | |
| 396 | + | assert.equal(spanSentence({ words: "Since this morning" }, null, null), "Since this morning, agent work couldn't be read."); | |
| 397 | + | assert.equal(waitingSentence(0), "Nothing needs you right now."); | |
| 398 | + | assert.equal(waitingSentence(1), "1 thing needs you."); | |
| 399 | + | assert.equal(waitingSentence(8), "8 things need you."); | |
| 400 | + | }); | |
| 401 | + | ||
| 402 | + | test("what landed: every merge in the span, a person's or an agent's, newest first", () => { | |
| 403 | + | const landed = landedIn( | |
| 404 | + | [ | |
| 405 | + | pull({ number: 1, mergedAt: iso(NOW - 2 * DAY), mergedBy: "bo" }), | |
| 406 | + | pull({ number: 2, author: { username: "bo", kind: "user" }, mergedAt: iso(NOW - HOUR) }), | |
| 407 | + | pull({ number: 3, mergedAt: iso(NOW - 5 * DAY) }), | |
| 408 | + | pull({ number: 4, status: "open", mergedAt: null }), | |
| 409 | + | ], | |
| 410 | + | SPAN, | |
| 411 | + | ); | |
| 412 | + | assert.deepEqual(landed.map((change) => [change.number, change.agent]), [ | |
| 413 | + | [2, false], | |
| 414 | + | [1, true], | |
| 415 | + | ]); | |
| 416 | + | assert.equal(landed[1]!.mergedBy, "bo"); | |
| 417 | + | }); | |
| 418 | + | ||
| 419 | + | const deploy = (over: Partial<Deployment>): Deployment => ({ | |
| 420 | + | id: "d1", | |
| 421 | + | kind: "production", | |
| 422 | + | branch: null, | |
| 423 | + | number: null, | |
| 424 | + | commit: "abcdef1234", | |
| 425 | + | status: "ready", | |
| 426 | + | url: "https://site.g1t.page", | |
| 427 | + | error: null, | |
| 428 | + | warnings: [], | |
| 429 | + | buildSeconds: 40, | |
| 430 | + | createdBy: "bo", | |
| 431 | + | createdAt: iso(NOW - 2 * HOUR), | |
| 432 | + | finishedAt: iso(NOW - HOUR), | |
| 433 | + | ...over, | |
| 434 | + | }); | |
| 435 | + | ||
| 436 | + | test("builds that finished in the span: live or failed, never skipped", () => { | |
| 437 | + | const rows = deploysIn( | |
| 438 | + | [ | |
| 439 | + | { | |
| 440 | + | slug: "site", | |
| 441 | + | deployments: [ | |
| 442 | + | deploy({}), | |
| 443 | + | deploy({ id: "d2", status: "replaced", finishedAt: iso(NOW - DAY) }), | |
| 444 | + | deploy({ id: "d3", status: "failed", finishedAt: iso(NOW - 2 * DAY) }), | |
| 445 | + | deploy({ id: "d4", status: "skipped", finishedAt: iso(NOW - HOUR) }), | |
| 446 | + | deploy({ id: "d5", status: "building", finishedAt: null }), | |
| 447 | + | deploy({ id: "d6", finishedAt: iso(NOW - 5 * DAY) }), | |
| 448 | + | ], | |
| 449 | + | }, | |
| 450 | + | ], | |
| 451 | + | "acme", | |
| 452 | + | SPAN, | |
| 453 | + | ); | |
| 454 | + | assert.deepEqual(rows.map((row) => [row.key, row.outcome]), [ | |
| 455 | + | ["deploy:site:d1", "live"], | |
| 456 | + | ["deploy:site:d2", "live"], | |
| 457 | + | ["deploy:site:d3", "failed"], | |
| 458 | + | ]); | |
| 459 | + | assert.equal(rows[0]!.to, "/acme/site/deployments/d1"); | |
| 460 | + | assert.equal(rows[0]!.commit, "abcdef1"); | |
| 461 | + | }); | |
| 462 | + | ||
| 463 | + | const request = (over: Partial<InstallRequest>): InstallRequest => ({ | |
| 464 | + | id: "r1", | |
| 465 | + | listing: "integration:sentry", | |
| 466 | + | kind: "integration", | |
| 467 | + | name: "Sentry", | |
| 468 | + | note: "For the error alerts", | |
| 469 | + | requested_by: "bo", | |
| 470 | + | requested_at: iso(NOW - 2 * DAY), | |
| 471 | + | status: "open", | |
| 472 | + | resolved_by: null, | |
| 473 | + | resolved_at: null, | |
| 474 | + | ...over, | |
| 475 | + | }); | |
| 476 | + | ||
| 477 | + | const memory = (over: Partial<Memory>): Memory => ({ | |
| 478 | + | id: "m1", | |
| 479 | + | scope: "workspace", | |
| 480 | + | workspace: "acme", | |
| 481 | + | repo: null, | |
| 482 | + | text: "Use Postgres for the ledger", | |
| 483 | + | kind: "decision", | |
| 484 | + | source: { kind: "person" } as Memory["source"], | |
| 485 | + | createdBy: "ada", | |
| 486 | + | pinned: false, | |
| 487 | + | createdAt: iso(NOW - DAY), | |
| 488 | + | updatedAt: iso(NOW - DAY), | |
| 489 | + | lastUsedAt: null, | |
| 490 | + | ...over, | |
| 491 | + | }); | |
| 492 | + | ||
| 493 | + | test("decisions: kept decisions in memory and requests answered, in the span", () => { | |
| 494 | + | const rows = decisionsIn( | |
| 495 | + | { | |
| 496 | + | memories: [memory({}), memory({ id: "m2", kind: "fact" }), memory({ id: "m3", status: "candidate" }), memory({ id: "m4", createdAt: iso(NOW - 6 * DAY) })], | |
| 497 | + | requests: [request({}), request({ id: "r2", status: "declined", resolved_by: "ada", resolved_at: iso(NOW - HOUR) })], | |
| 498 | + | }, | |
| 499 | + | "acme", | |
| 500 | + | SPAN, | |
| 501 | + | ); | |
| 502 | + | assert.deepEqual(rows.map((row) => row.key), ["request:r2", "memory:m1"]); | |
| 503 | + | assert.equal(rows[0]!.text, "Turned down Sentry, which bo asked for"); | |
| 504 | + | }); | |
| 505 | + | ||
| 506 | + | test("an open install request needs an owner; answered ones don't", () => { | |
| 507 | + | assert.equal(installRequestRow(request({}), "acme")?.action.to, "/acme/-/marketplace/requests"); | |
| 508 | + | assert.equal(installRequestRow(request({ status: "done" }), "acme"), null); | |
| 509 | + | const base = { slug: "acme", code: true, codeNeeds: [], capped: [], agents: [], canManage: true, notifications: [], chat: [] }; | |
| 510 | + | assert.equal(attention({ ...base, requests: [request({})] }).rows[0]?.kind, "install_request"); | |
| 511 | + | assert.equal(attention({ ...base, requests: null }).rows.length, 0); | |
| 512 | + | }); | |
| 513 | + | ||
| 514 | + | test("the workspace's agent budget used up comes first, for those who may raise it", () => { | |
| 515 | + | assert.equal(limitRow({ alert: 90, spentMicros: 1, since: NOW }, "acme"), null); | |
| 516 | + | const limit = { alert: 100, spentMicros: 500_000_000, since: NOW - DAY }; | |
| 517 | + | assert.equal(limitRow(limit, "acme")?.action.to, "/acme/-/spend"); | |
| 518 | + | const base = { slug: "acme", code: true, codeNeeds: [need({})], capped: [], agents: [], notifications: [], chat: [], limit }; | |
| 519 | + | assert.equal(attention({ ...base, canManage: true }).start?.row.kind, "limit"); | |
| 520 | + | assert.equal(attention({ ...base, canManage: false }).rows.some((r) => r.kind === "limit"), false); | |
| 521 | + | }); | |
| 522 | + | ||
| 523 | + | test("running now: live root sessions, agents' changes and builds going; a capped session is under Needs you", () => { | |
| 524 | + | const sessions = runningSessions( | |
| 525 | + | [ | |
| 526 | + | session({ id: "a", status: "working", finished_at: null, created_at: iso(NOW - HOUR) }), | |
| 527 | + | session({ id: "b", status: "needs_approval", finished_at: null }), | |
| 528 | + | session({ id: "c", status: "queued", finished_at: null, parent_id: "a" }), | |
| 529 | + | session({ id: "d", status: "done" }), | |
| 530 | + | session({ id: "e", status: "queued", finished_at: null, visible: false, created_at: iso(NOW - 2 * HOUR) }), | |
| 531 | + | ], | |
| 532 | + | "acme", | |
| 533 | + | ); | |
| 534 | + | assert.deepEqual(sessions.map((row) => [row.key, row.status]), [ | |
| 535 | + | ["session:a", "Working"], | |
| 536 | + | ["session:e", "Queued"], | |
| 537 | + | ]); | |
| 538 | + | assert.equal(sessions[1]!.title, "A session in a conversation you're not in"); | |
| 539 | + | const builds = runningDeploys( | |
| 540 | + | [ | |
| 541 | + | { slug: "site", enabled: true, production: null, previews: 0, latest: deploy({ status: "building", finishedAt: null }) }, | |
| 542 | + | { slug: "docs", enabled: true, production: null, previews: 0, latest: deploy({}) }, | |
| 543 | + | ], | |
| 544 | + | "acme", | |
| 545 | + | ); | |
| 546 | + | assert.deepEqual(builds.map((row) => row.status), ["Building"]); | |
| 547 | + | // Longest-going first within a kind. | |
| 548 | + | assert.deepEqual(running([builds, sessions]).map((row) => row.key), ["session:e", "session:a", "deploy:site:d1"]); | |
| 549 | + | }); | |
| 550 | + | ||
| 551 | + | ||
| 552 | + | test("the span's months: one, or the ones it crosses", () => { | |
| 553 | + | assert.deepEqual(spanMonths(SPAN), ["2026-10"]); | |
| 554 | + | assert.deepEqual(spanMonths({ from: Date.parse("2026-09-28T00:00:00Z"), now: NOW }), ["2026-09", "2026-10"]); | |
| 555 | + | assert.deepEqual(spanMonths({ from: Date.parse("2025-12-30T00:00:00Z"), now: Date.parse("2026-01-02T00:00:00Z") }), ["2025-12", "2026-01"]); | |
| 556 | + | }); | |
| 557 | + | ||
| 558 | + | test("spend in the span: the UTC days it covers across months, at price, without money in", () => { | |
| 559 | + | const september = { | |
| 560 | + | groups: [ | |
| 561 | + | { key: "2026-09-29", label: "", chargedMicros: 0, lines: [{ kind: "Agent runs", count: 2, chargedMicros: 1_000_000, costMicros: 0 }] }, | |
| 562 | + | { key: "2026-09-20", label: "", chargedMicros: 0, lines: [{ kind: "Agent runs", count: 9, chargedMicros: 9_000_000, costMicros: 0 }] }, | |
| 563 | + | ], | |
| 564 | + | totals: { chargedMicros: 10_000_000, paidMicros: 0, costMicros: 0, entries: 2 }, | |
| 565 | + | }; | |
| 566 | + | const october = { | |
| 567 | + | groups: [ | |
| 568 | + | { | |
| 569 | + | key: "2026-10-09", | |
| 570 | + | label: "2026-10-09", | |
| 571 | + | chargedMicros: 0, | |
| 572 | + | lines: [ | |
| 573 | + | { kind: "Agent runs", count: 12, chargedMicros: 3_000_000, costMicros: 2_500_000, priceMicros: 4_000_000 }, | |
| 574 | + | { kind: "Sandbox time", count: 12, chargedMicros: 600_000, costMicros: 500_000 }, | |
| 575 | + | { kind: "Payments", count: 1, chargedMicros: -50_000_000, costMicros: 0, priceMicros: 0 }, | |
| 576 | + | ], | |
| 577 | + | }, | |
| 578 | + | ], | |
| 579 | + | totals: { chargedMicros: 12_600_000, paidMicros: 50_000_000, costMicros: 0, entries: 4, priceMicros: 13_600_000 }, | |
| 580 | + | }; | |
| 581 | + | const spend = spendIn([september, october], { from: Date.parse("2026-09-29T15:00:00Z"), now: NOW }); | |
| 582 | + | assert.deepEqual([spend.from, spend.to], ["2026-09-29", "2026-10-09"]); | |
| 583 | + | assert.equal(spend.totalMicros, 5_600_000); | |
| 584 | + | assert.deepEqual(spend.lines.map((l) => [l.kind, l.micros, l.count]), [ | |
| 585 | + | ["Agent runs", 5_000_000, 14], | |
| 586 | + | ["Sandbox time", 600_000, 12], | |
| 587 | + | ]); | |
| 588 | + | // The month so far is the current month's statement. | |
| 589 | + | assert.equal(spend.monthMicros, 13_600_000); | |
| 590 | + | }); |
| 1 | + | /** | |
| 2 | + | * Home, the workspace's front page: where you're needed now, what happened | |
| 3 | + | * since you were last here, and what is running. It never depends on the | |
| 4 | + | * calendar: what needs you has no time window, and what happened is | |
| 5 | + | * counted over a span you choose, since your last visit by default. Every | |
| 6 | + | * number is worked out here from what the services return, so each | |
| 7 | + | * definition is tested on its own (home.test.ts) and written down in the | |
| 8 | + | * Home guide (apps/docs/.../guides/home.md). Imports only types. | |
| 9 | + | */ | |
| 10 | + | import type { | |
| 11 | + | AgentSession, | |
| 12 | + | AgentSessionKind, | |
| 13 | + | Deployment, | |
| 14 | + | InboxItem, | |
| 15 | + | InstallRequest, | |
| 16 | + | Memory, | |
| 17 | + | ProjectDeploys, | |
| 18 | + | Pull, | |
| 19 | + | RepoPath, | |
| 20 | + | Statement, | |
| 21 | + | } from "@g1t/contracts"; | |
| 22 | + | ||
| 23 | + | const MINUTE = 60_000; | |
| 24 | + | const HOUR = 60 * MINUTE; | |
| 25 | + | const DAY = 24 * HOUR; | |
| 26 | + | ||
| 27 | + | /** A day's date in a time zone, as `YYYY-MM-DD`; UTC when the zone is unknown or invalid. */ | |
| 28 | + | export function dayIn(at: number, timeZone: string | null): string { | |
| 29 | + | try { | |
| 30 | + | return new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone: timeZone || "UTC" }).format(at); | |
| 31 | + | } catch { | |
| 32 | + | return new Date(at).toISOString().slice(0, 10); | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /** The hour of the day (0–23) in a time zone; UTC when it is unknown. */ | |
| 37 | + | export function hourIn(at: number, timeZone: string | null): number { | |
| 38 | + | const read = (zone: string) => Number(new Intl.DateTimeFormat("en-US", { hour: "numeric", hourCycle: "h23", timeZone: zone }).format(at)) % 24; | |
| 39 | + | try { | |
| 40 | + | return read(timeZone || "UTC"); | |
| 41 | + | } catch { | |
| 42 | + | return read("UTC"); | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | function formatIn(at: number, timeZone: string | null, options: Intl.DateTimeFormatOptions): string { | |
| 47 | + | try { | |
| 48 | + | return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone || "UTC" }).format(at); | |
| 49 | + | } catch { | |
| 50 | + | return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(at); | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /** "Oct 9", in the reader's time zone. */ | |
| 55 | + | export function shortDate(at: number, timeZone: string | null): string { | |
| 56 | + | return formatIn(at, timeZone, { month: "short", day: "numeric" }); | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // --- The span ----------------------------------------------------------------- | |
| 60 | + | ||
| 61 | + | /** Which span "what happened" covers: since your last visit, or a fixed one. */ | |
| 62 | + | export type WindowKey = "last" | "24h" | "7d"; | |
| 63 | + | ||
| 64 | + | export const WINDOWS: { key: WindowKey; label: string }[] = [ | |
| 65 | + | { key: "last", label: "Since last visit" }, | |
| 66 | + | { key: "24h", label: "Last 24 hours" }, | |
| 67 | + | { key: "7d", label: "Last 7 days" }, | |
| 68 | + | ]; | |
| 69 | + | ||
| 70 | + | /** The furthest back "since your last visit" looks. */ | |
| 71 | + | export const MAX_AWAY_DAYS = 14; | |
| 72 | + | ||
| 73 | + | /** A `?window=` value, or the default: since your last visit. */ | |
| 74 | + | export function parseWindow(value: string | null | undefined): WindowKey { | |
| 75 | + | return value === "24h" || value === "7d" ? value : "last"; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | export type Span = { | |
| 79 | + | key: WindowKey; | |
| 80 | + | /** Epoch ms: the start of the span. */ | |
| 81 | + | from: number; | |
| 82 | + | now: number; | |
| 83 | + | /** "Since Tuesday evening", "In the last 24 hours". */ | |
| 84 | + | words: string; | |
| 85 | + | /** How long it covers: "3 days", "45 minutes". */ | |
| 86 | + | length: string; | |
| 87 | + | /** | |
| 88 | + | * Why "since your last visit" covers something else: `first` when there | |
| 89 | + | * is no visit on record (it shows the last 24 hours), `capped` when the | |
| 90 | + | * last one was longer ago than MAX_AWAY_DAYS, `unknown` when it could | |
| 91 | + | * not be read (it shows the last 24 hours). | |
| 92 | + | */ | |
| 93 | + | note: "first" | "capped" | "unknown" | null; | |
| 94 | + | }; | |
| 95 | + | ||
| 96 | + | const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`; | |
| 97 | + | ||
| 98 | + | /** How long a stretch of time is, roughly: "a moment", "40 minutes", "3 hours", "3 days", "2 weeks". */ | |
| 99 | + | export function duration(ms: number): string { | |
| 100 | + | const minutes = Math.floor(Math.max(0, ms) / MINUTE); | |
| 101 | + | if (minutes < 1) return "a moment"; | |
| 102 | + | if (minutes < 60) return plural(minutes, "minute"); | |
| 103 | + | const hours = Math.floor(minutes / 60); | |
| 104 | + | if (hours < 24) return plural(hours, "hour"); | |
| 105 | + | const days = Math.floor(hours / 24); | |
| 106 | + | if (days < 14) return plural(days, "day"); | |
| 107 | + | return plural(Math.floor(days / 7), "week"); | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | type Part = "night" | "morning" | "afternoon" | "evening"; | |
| 111 | + | ||
| 112 | + | /** | |
| 113 | + | * When `from` was, in words, from `now`: "a moment ago", "40 minutes ago", | |
| 114 | + | * "2 hours ago", "this morning", "last night", "yesterday evening", | |
| 115 | + | * "Tuesday evening", "Oct 2". The small hours belong to the night before: | |
| 116 | + | * 2 a.m. on Wednesday is Tuesday night. | |
| 117 | + | */ | |
| 118 | + | export function sinceWords(from: number, now: number, timeZone: string | null): string { | |
| 119 | + | const ago = now - from; | |
| 120 | + | if (ago < MINUTE) return "a moment ago"; | |
| 121 | + | if (ago < HOUR) return `${plural(Math.floor(ago / MINUTE), "minute")} ago`; | |
| 122 | + | if (ago < 3 * HOUR) return Math.floor(ago / HOUR) === 1 ? "an hour ago" : `${Math.floor(ago / HOUR)} hours ago`; | |
| 123 | + | const hour = hourIn(from, timeZone); | |
| 124 | + | const part: Part = hour < 5 ? "night" : hour < 12 ? "morning" : hour < 17 ? "afternoon" : "evening"; | |
| 125 | + | // The night is the evening's day's. | |
| 126 | + | const day = dayIn(part === "night" ? from - 6 * HOUR : from, timeZone); | |
| 127 | + | const today = dayIn(now, timeZone); | |
| 128 | + | const yesterday = dayIn(now - DAY, timeZone); | |
| 129 | + | const twoDaysAgo = dayIn(now - 2 * DAY, timeZone); | |
| 130 | + | if (day === today) return `this ${part === "night" ? "evening" : part}`; | |
| 131 | + | if (day === yesterday) return part === "night" ? "last night" : `yesterday ${part}`; | |
| 132 | + | if (part === "night" && day === twoDaysAgo) return "the night before last"; | |
| 133 | + | if (ago < 6 * DAY) return `${formatIn(part === "night" ? from - 6 * HOUR : from, timeZone, { weekday: "long" })} ${part}`; | |
| 134 | + | return shortDate(from, timeZone); | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /** | |
| 138 | + | * The span for `key`: since `lastSeen` (your last visit, kept by notify), | |
| 139 | + | * or the last 24 hours or 7 days. Since your last visit falls back to the | |
| 140 | + | * last 24 hours when there is none (`null`) or it could not be read | |
| 141 | + | * (`undefined`), and to MAX_AWAY_DAYS when it was longer ago. | |
| 142 | + | */ | |
| 143 | + | export function spanFor(key: WindowKey, now: number, lastSeen: number | null | undefined, timeZone: string | null): Span { | |
| 144 | + | if (key === "7d") return { key, from: now - 7 * DAY, now, words: "In the last 7 days", length: "7 days", note: null }; | |
| 145 | + | if (key === "24h" || lastSeen == null || lastSeen > now) { | |
| 146 | + | const note = key !== "last" ? null : lastSeen === undefined ? "unknown" : "first"; | |
| 147 | + | return { key, from: now - DAY, now, words: "In the last 24 hours", length: "24 hours", note }; | |
| 148 | + | } | |
| 149 | + | const furthest = now - MAX_AWAY_DAYS * DAY; | |
| 150 | + | if (lastSeen < furthest) return { key, from: furthest, now, words: `In the last ${MAX_AWAY_DAYS} days`, length: `${MAX_AWAY_DAYS} days`, note: "capped" }; | |
| 151 | + | return { key, from: lastSeen, now, words: `Since ${sinceWords(lastSeen, now, timeZone)}`, length: duration(now - lastSeen), note: null }; | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | /** Whether `at` (epoch ms or RFC 3339) falls in the span. */ | |
| 155 | + | export function within(at: number | string | null | undefined, span: Pick<Span, "from" | "now">): boolean { | |
| 156 | + | if (at == null) return false; | |
| 157 | + | const ms = typeof at === "number" ? at : Date.parse(at); | |
| 158 | + | return Number.isFinite(ms) && ms >= span.from && ms <= span.now; | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // --- Agent work in the span --------------------------------------------------- | |
| 162 | + | ||
| 163 | + | /** | |
| 164 | + | * How one piece of agent work that settled in the span went: | |
| 165 | + | * - `accepted`: an agent's pull request merged without the agent being sent back to revise it. | |
| 166 | + | * - `fixed`: an agent's pull request merged after one or more revisions. | |
| 167 | + | * - `finished`: an agent session that finished. Sessions are not reviewed, so they are never `accepted`. | |
| 168 | + | * - `dropped`: an agent's pull request closed without merging, or a session that failed or was stopped. | |
| 169 | + | * Work still going is not here: it is under Running now. | |
| 170 | + | */ | |
| 171 | + | export type TaskOutcome = "accepted" | "fixed" | "finished" | "dropped"; | |
| 172 | + | ||
| 173 | + | /** Where a task came from. */ | |
| 174 | + | export type TaskSource = "chat" | "schedule" | "colleague" | "code"; | |
| 175 | + | ||
| 176 | + | export const OUTCOME_LABEL: Record<TaskOutcome, string> = { | |
| 177 | + | accepted: "Accepted first time", | |
| 178 | + | fixed: "Fixed after review", | |
| 179 | + | finished: "Sessions finished", | |
| 180 | + | dropped: "Didn't finish", | |
| 181 | + | }; | |
| 182 | + | ||
| 183 | + | export const SOURCE_LABEL: Record<TaskSource, string> = { | |
| 184 | + | chat: "Chat", | |
| 185 | + | schedule: "Schedules", | |
| 186 | + | colleague: "Another agent", | |
| 187 | + | code: "Code", | |
| 188 | + | }; | |
| 189 | + | ||
| 190 | + | /** One mark on the strip. */ | |
| 191 | + | export type WorkTask = { | |
| 192 | + | key: string; | |
| 193 | + | outcome: TaskOutcome; | |
| 194 | + | source: TaskSource; | |
| 195 | + | /** Null for a session in a conversation the viewer is not in. */ | |
| 196 | + | title: string | null; | |
| 197 | + | to: string | null; | |
| 198 | + | /** When it settled: the strip's order. */ | |
| 199 | + | at: number; | |
| 200 | + | }; | |
| 201 | + | ||
| 202 | + | /** A pull request with where it is: what Home reads from Code. */ | |
| 203 | + | export type CodePull = Pick<Pull, "number" | "title" | "status" | "mergedAt" | "createdAt" | "updatedAt"> & { | |
| 204 | + | repo: RepoPath; | |
| 205 | + | author: { username: string; kind?: string }; | |
| 206 | + | /** Who merged it, when it merged. */ | |
| 207 | + | mergedBy?: string | null; | |
| 208 | + | }; | |
| 209 | + | ||
| 210 | + | /** Whether a pull request is an agent's change: g1t made it, or an agent account opened it. */ | |
| 211 | + | export function isAgentPull(pull: { author: { username: string; kind?: string } }): boolean { | |
| 212 | + | const name = pull.author.username; | |
| 213 | + | return pull.author.kind === "agent" || name === "g1t" || name.endsWith("-agent"); | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | /** `acme/web#12`, lowercase: how a pull request and its runs are matched. */ | |
| 217 | + | export function pullKey(repo: RepoPath, number: number): string { | |
| 218 | + | return `${repo.namespace}/${repo.name}#${number}`.toLowerCase(); | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | /** | |
| 222 | + | * How an agent's pull request settled in the span, or null when it did not | |
| 223 | + | * settle in it. `revisions` counts its `revise` runs: each time g1t sent | |
| 224 | + | * the agent back for failed checks or a review. | |
| 225 | + | */ | |
| 226 | + | export function pullOutcome(pull: CodePull, revisions: number, span: Pick<Span, "from" | "now">): TaskOutcome | null { | |
| 227 | + | if (pull.status === "merged") return within(pull.mergedAt, span) ? (revisions > 0 ? "fixed" : "accepted") : null; | |
| 228 | + | if (pull.status === "closed") return within(pull.updatedAt, span) ? "dropped" : null; | |
| 229 | + | return null; | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | const LIVE: ReadonlySet<string> = new Set(["queued", "working", "waiting", "needs_approval"]); | |
| 233 | + | ||
| 234 | + | /** | |
| 235 | + | * How a session settled in the span, or null. Only a session at the root | |
| 236 | + | * of its tree counts: the subagents and colleagues it brought in are part | |
| 237 | + | * of its task. | |
| 238 | + | */ | |
| 239 | + | export function sessionOutcome( | |
| 240 | + | session: Pick<AgentSession, "parent_id" | "status" | "updated_at" | "finished_at">, | |
| 241 | + | span: Pick<Span, "from" | "now">, | |
| 242 | + | ): TaskOutcome | null { | |
| 243 | + | if (session.parent_id || LIVE.has(session.status)) return null; | |
| 244 | + | if (!within(session.finished_at ?? session.updated_at, span)) return null; | |
| 245 | + | return session.status === "done" ? "finished" : "dropped"; | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | export function sessionSource(kind: AgentSessionKind): TaskSource { | |
| 249 | + | return kind === "routine" ? "schedule" : kind === "chat" ? "chat" : "colleague"; | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | /** The agents' work that settled in the span, and how the 7 days before it compare. */ | |
| 253 | + | export type SpanWork = { | |
| 254 | + | tasks: WorkTask[]; | |
| 255 | + | counts: Record<TaskOutcome, number>; | |
| 256 | + | /** Tasks by where they came from, most first; only sources with any. */ | |
| 257 | + | sources: { source: TaskSource; count: number }[]; | |
| 258 | + | /** | |
| 259 | + | * Accepted first time: of the agents' pull requests that merged or closed | |
| 260 | + | * in the span, the share that merged without a revision. Null with none. | |
| 261 | + | */ | |
| 262 | + | rate: { value: number; of: number } | null; | |
| 263 | + | /** The same share over the 7 days before the span; null when there were none, or they could not all be read. */ | |
| 264 | + | before: { value: number; of: number } | null; | |
| 265 | + | /** Whether Code was read: false for a member without Code access, or when Code did not answer. */ | |
| 266 | + | code: "read" | "no_access" | "unavailable"; | |
| 267 | + | /** Whether sessions were read. */ | |
| 268 | + | sessions: "read" | "unavailable"; | |
| 269 | + | /** True when a list was cut short before the start of the span, so some work may be missing. */ | |
| 270 | + | partial: boolean; | |
| 271 | + | }; | |
| 272 | + | ||
| 273 | + | export type CodeWork = { | |
| 274 | + | pulls: CodePull[]; | |
| 275 | + | /** `revise` runs per pull request (`pullKey`). */ | |
| 276 | + | revisions: Record<string, number>; | |
| 277 | + | /** Whether every list read reached back to 7 days before the span. */ | |
| 278 | + | complete: boolean; | |
| 279 | + | }; | |
| 280 | + | ||
| 281 | + | export type SessionWork = { sessions: AgentSession[]; complete: boolean }; | |
| 282 | + | ||
| 283 | + | /** Accepted first time over a set of settled outcomes: null when no pull request settled. */ | |
| 284 | + | export function acceptance(outcomes: TaskOutcome[]): { value: number; of: number } | null { | |
| 285 | + | const settled = outcomes.filter((o) => o === "accepted" || o === "fixed" || o === "dropped"); | |
| 286 | + | if (settled.length === 0) return null; | |
| 287 | + | return { value: settled.filter((o) => o === "accepted").length / settled.length, of: settled.length }; | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | export function spanWork(input: { | |
| 291 | + | span: Pick<Span, "from" | "now">; | |
| 292 | + | slug: string; | |
| 293 | + | code: CodeWork | null | "no_access"; | |
| 294 | + | sessions: SessionWork | null; | |
| 295 | + | }): SpanWork { | |
| 296 | + | const { span, slug } = input; | |
| 297 | + | const tasks: WorkTask[] = []; | |
| 298 | + | let before: SpanWork["before"] = null; | |
| 299 | + | const code = input.code; | |
| 300 | + | if (code && code !== "no_access") { | |
| 301 | + | const prior = { from: span.from - 7 * DAY, now: span.from - 1 }; | |
| 302 | + | const earlier: TaskOutcome[] = []; | |
| 303 | + | for (const pull of code.pulls) { | |
| 304 | + | if (!isAgentPull(pull)) continue; | |
| 305 | + | const revisions = code.revisions[pullKey(pull.repo, pull.number)] ?? 0; | |
| 306 | + | const to = `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`; | |
| 307 | + | const outcome = pullOutcome(pull, revisions, span); | |
| 308 | + | if (outcome) { | |
| 309 | + | const at = Date.parse(pull.status === "merged" && pull.mergedAt ? pull.mergedAt : pull.updatedAt); | |
| 310 | + | tasks.push({ key: `pull:${pullKey(pull.repo, pull.number)}`, outcome, source: "code", title: pull.title, to, at }); | |
| 311 | + | continue; | |
| 312 | + | } | |
| 313 | + | const then = pullOutcome(pull, revisions, prior); | |
| 314 | + | if (then) earlier.push(then); | |
| 315 | + | } | |
| 316 | + | before = code.complete ? acceptance(earlier) : null; | |
| 317 | + | } | |
| 318 | + | for (const session of input.sessions?.sessions ?? []) { | |
| 319 | + | const outcome = sessionOutcome(session, span); | |
| 320 | + | if (!outcome) continue; | |
| 321 | + | tasks.push({ | |
| 322 | + | key: `session:${session.id}`, | |
| 323 | + | outcome, | |
| 324 | + | source: sessionSource(session.kind), | |
| 325 | + | title: session.visible ? session.title : null, | |
| 326 | + | to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}`, | |
| 327 | + | at: Date.parse(session.finished_at ?? session.updated_at), | |
| 328 | + | }); | |
| 329 | + | } | |
| 330 | + | // In the order they settled. | |
| 331 | + | tasks.sort((a, b) => a.at - b.at || a.key.localeCompare(b.key)); | |
| 332 | + | const counts: Record<TaskOutcome, number> = { accepted: 0, fixed: 0, finished: 0, dropped: 0 }; | |
| 333 | + | const bySource = new Map<TaskSource, number>(); | |
| 334 | + | for (const task of tasks) { | |
| 335 | + | counts[task.outcome]++; | |
| 336 | + | bySource.set(task.source, (bySource.get(task.source) ?? 0) + 1); | |
| 337 | + | } | |
| 338 | + | const sourceOrder: TaskSource[] = ["chat", "code", "schedule", "colleague"]; | |
| 339 | + | return { | |
| 340 | + | tasks, | |
| 341 | + | counts, | |
| 342 | + | sources: [...bySource] | |
| 343 | + | .map(([source, count]) => ({ source, count })) | |
| 344 | + | .sort((a, b) => b.count - a.count || sourceOrder.indexOf(a.source) - sourceOrder.indexOf(b.source)), | |
| 345 | + | rate: acceptance(tasks.filter((t) => t.source === "code").map((t) => t.outcome)), | |
| 346 | + | before, | |
| 347 | + | code: code === "no_access" ? "no_access" : code ? "read" : "unavailable", | |
| 348 | + | sessions: input.sessions ? "read" : "unavailable", | |
| 349 | + | partial: (code != null && code !== "no_access" && !code.complete) || (input.sessions != null && !input.sessions.complete), | |
| 350 | + | }; | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | /** The change in percentage points from the 7 days before, rounded: "+6 pts vs the 7 days before", "Same as the 7 days before". */ | |
| 354 | + | export function trendLabel(rate: { value: number } | null, before: { value: number } | null): string | null { | |
| 355 | + | if (!rate || !before) return null; | |
| 356 | + | const points = Math.round(rate.value * 100) - Math.round(before.value * 100); | |
| 357 | + | if (points === 0) return "Same as the 7 days before"; | |
| 358 | + | return `${points > 0 ? "+" : "−"}${Math.abs(points)} pts vs the 7 days before`; | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | // --- What else happened in the span ------------------------------------------- | |
| 362 | + | ||
| 363 | + | /** A change that landed: a pull request merged, by a person or an agent. */ | |
| 364 | + | export type LandedChange = { | |
| 365 | + | key: string; | |
| 366 | + | title: string; | |
| 367 | + | repo: RepoPath; | |
| 368 | + | number: number; | |
| 369 | + | to: string; | |
| 370 | + | at: number; | |
| 371 | + | /** An agent's change, or a person's. */ | |
| 372 | + | agent: boolean; | |
| 373 | + | author: string; | |
| 374 | + | mergedBy: string | null; | |
| 375 | + | }; | |
| 376 | + | ||
| 377 | + | /** Every pull request that merged in the span, newest first. */ | |
| 378 | + | export function landedIn(pulls: CodePull[], span: Pick<Span, "from" | "now">): LandedChange[] { | |
| 379 | + | return pulls | |
| 380 | + | .filter((pull) => pull.status === "merged" && within(pull.mergedAt, span)) | |
| 381 | + | .map((pull) => ({ | |
| 382 | + | key: `landed:${pullKey(pull.repo, pull.number)}`, | |
| 383 | + | title: pull.title, | |
| 384 | + | repo: pull.repo, | |
| 385 | + | number: pull.number, | |
| 386 | + | to: `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`, | |
| 387 | + | at: Date.parse(pull.mergedAt!), | |
| 388 | + | agent: isAgentPull(pull), | |
| 389 | + | author: pull.author.username, | |
| 390 | + | mergedBy: pull.mergedBy ?? null, | |
| 391 | + | })) | |
| 392 | + | .sort((a, b) => b.at - a.at || a.key.localeCompare(b.key)); | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | /** A build that finished in the span. */ | |
| 396 | + | export type DeployRow = { | |
| 397 | + | key: string; | |
| 398 | + | project: string; | |
| 399 | + | kind: Deployment["kind"]; | |
| 400 | + | /** `live`: built and served (it may have been replaced since); `failed`: it did not build. */ | |
| 401 | + | outcome: "live" | "failed"; | |
| 402 | + | commit: string; | |
| 403 | + | branch: string | null; | |
| 404 | + | at: number; | |
| 405 | + | to: string; | |
| 406 | + | by: string; | |
| 407 | + | }; | |
| 408 | + | ||
| 409 | + | /** | |
| 410 | + | * Builds that finished in the span, newest first: production builds each, | |
| 411 | + | * and previews too. A build that went live and was replaced since still | |
| 412 | + | * went live. Skipped builds are left out. | |
| 413 | + | */ | |
| 414 | + | export function deploysIn(projects: { slug: string; deployments: Deployment[] }[], workspace: string, span: Pick<Span, "from" | "now">): DeployRow[] { | |
| 415 | + | const rows: DeployRow[] = []; | |
| 416 | + | for (const project of projects) { | |
| 417 | + | for (const deploy of project.deployments) { | |
| 418 | + | const ended = deploy.finishedAt ?? null; | |
| 419 | + | if (!within(ended, span)) continue; | |
| 420 | + | const outcome = deploy.status === "failed" ? "failed" : deploy.status === "ready" || deploy.status === "replaced" || deploy.status === "down" ? "live" : null; | |
| 421 | + | if (!outcome) continue; | |
| 422 | + | rows.push({ | |
| 423 | + | key: `deploy:${project.slug}:${deploy.id}`, | |
| 424 | + | project: project.slug, | |
| 425 | + | kind: deploy.kind, | |
| 426 | + | outcome, | |
| 427 | + | commit: deploy.commit.slice(0, 7), | |
| 428 | + | branch: deploy.branch, | |
| 429 | + | at: Date.parse(ended!), | |
| 430 | + | to: `/${workspace}/${project.slug}/deployments/${deploy.id}`, | |
| 431 | + | by: deploy.createdBy, | |
| 432 | + | }); | |
| 433 | + | } | |
| 434 | + | } | |
| 435 | + | return rows.sort((a, b) => b.at - a.at || a.key.localeCompare(b.key)); | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | /** A decision made in the span: one an agent or a person recorded in the workspace's memory, or a request an owner answered. */ | |
| 439 | + | export type DecisionRow = { | |
| 440 | + | key: string; | |
| 441 | + | text: string; | |
| 442 | + | by: string | null; | |
| 443 | + | at: number; | |
| 444 | + | to: string; | |
| 445 | + | kind: "memory" | "request"; | |
| 446 | + | }; | |
| 447 | + | ||
| 448 | + | export function decisionsIn( | |
| 449 | + | input: { memories: Memory[] | null; requests: InstallRequest[] | null }, | |
| 450 | + | slug: string, | |
| 451 | + | span: Pick<Span, "from" | "now">, | |
| 452 | + | ): DecisionRow[] { | |
| 453 | + | const rows: DecisionRow[] = []; | |
| 454 | + | for (const memory of input.memories ?? []) { | |
| 455 | + | if (memory.kind !== "decision" || (memory.status != null && memory.status !== "kept") || !within(memory.createdAt, span)) continue; | |
| 456 | + | rows.push({ key: `memory:${memory.id}`, text: memory.text, by: memory.createdBy, at: Date.parse(memory.createdAt), to: `/${slug}/-/memory`, kind: "memory" }); | |
| 457 | + | } | |
| 458 | + | for (const request of input.requests ?? []) { | |
| 459 | + | if (request.status === "open" || !within(request.resolved_at, span)) continue; | |
| 460 | + | rows.push({ | |
| 461 | + | key: `request:${request.id}`, | |
| 462 | + | text: `${request.status === "done" ? "Added" : "Turned down"} ${request.name}, which ${request.requested_by} asked for`, | |
| 463 | + | by: request.resolved_by, | |
| 464 | + | at: Date.parse(request.resolved_at!), | |
| 465 | + | to: `/${slug}/-/marketplace/requests`, | |
| 466 | + | kind: "request", | |
| 467 | + | }); | |
| 468 | + | } | |
| 469 | + | return rows.sort((a, b) => b.at - a.at || a.key.localeCompare(b.key)); | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | // --- Running now ---------------------------------------------------------------- | |
| 473 | + | ||
| 474 | + | /** Something going right now: a session, an agent's change, a build. */ | |
| 475 | + | export type RunningRow = { | |
| 476 | + | key: string; | |
| 477 | + | kind: "session" | "change" | "deploy"; | |
| 478 | + | title: string; | |
| 479 | + | /** One line: who and where. */ | |
| 480 | + | detail: string; | |
| 481 | + | /** "Working", "Queued", "Building". */ | |
| 482 | + | status: string; | |
| 483 | + | /** Epoch ms: when it started. */ | |
| 484 | + | at: number; | |
| 485 | + | to: string; | |
| 486 | + | }; | |
| 487 | + | ||
| 488 | + | const SESSION_STATUS: Record<string, string> = { queued: "Queued", working: "Working", waiting: "Waiting on its helpers" }; | |
| 489 | + | ||
| 490 | + | /** Live sessions at the root of their tree; one stopped at its cap waits on a person and is under Needs you instead. */ | |
| 491 | + | export function runningSessions(sessions: AgentSession[], slug: string): RunningRow[] { | |
| 492 | + | return sessions | |
| 493 | + | .filter((session) => !session.parent_id && SESSION_STATUS[session.status]) | |
| 494 | + | .map((session) => ({ | |
| 495 | + | key: `session:${session.id}`, | |
| 496 | + | kind: "session" as const, | |
| 497 | + | title: session.visible ? session.title : "A session in a conversation you're not in", | |
| 498 | + | detail: `@${session.agent_handle}${session.channel_name ? ` in #${session.channel_name}` : ""}`, | |
| 499 | + | status: SESSION_STATUS[session.status]!, | |
| 500 | + | at: Date.parse(session.created_at), | |
| 501 | + | to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}`, | |
| 502 | + | })); | |
| 503 | + | } | |
| 504 | + | ||
| 505 | + | /** Mission control's "waiting on agents" row, as Home reads it (lib/mission-control.ts `WaitingRow`). */ | |
| 506 | + | export type AgentChange = { key: string; repo: RepoPath; ref: string | null; title: string; chip: string; by: { name: string } | null; at: number; to: string }; | |
| 507 | + | ||
| 508 | + | export function runningChanges(changes: AgentChange[]): RunningRow[] { | |
| 509 | + | return changes.map((change) => ({ | |
| 510 | + | key: `change:${change.key}`, | |
| 511 | + | kind: "change" as const, | |
| 512 | + | title: change.title, | |
| 513 | + | detail: `${change.by ? `@${change.by.name} · ` : ""}${change.repo.namespace}/${change.repo.name}${change.ref ? ` ${change.ref}` : ""}`, | |
| 514 | + | status: change.chip, | |
| 515 | + | at: change.at, | |
| 516 | + | to: change.to, | |
| 517 | + | })); | |
| 518 | + | } | |
| 519 | + | ||
| 520 | + | /** Builds queued or going now, one per project (its newest). */ | |
| 521 | + | export function runningDeploys(overview: ProjectDeploys[], slug: string): RunningRow[] { | |
| 522 | + | return overview.flatMap((project) => { | |
| 523 | + | const latest = project.latest; | |
| 524 | + | if (!latest || (latest.status !== "queued" && latest.status !== "building")) return []; | |
| 525 | + | return [ | |
| 526 | + | { | |
| 527 | + | key: `deploy:${project.slug}:${latest.id}`, | |
| 528 | + | kind: "deploy" as const, | |
| 529 | + | title: `${latest.kind === "production" ? "Production" : `Preview of ${latest.branch ?? "a branch"}`} of ${project.slug}`, | |
| 530 | + | detail: `${latest.commit.slice(0, 7)} · by ${latest.createdBy}`, | |
| 531 | + | status: latest.status === "queued" ? "Queued" : "Building", | |
| 532 | + | at: Date.parse(latest.createdAt), | |
| 533 | + | to: `/${slug}/${project.slug}/deployments/${latest.id}`, | |
| 534 | + | }, | |
| 535 | + | ]; | |
| 536 | + | }); | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | /** Everything running, longest-going first within each kind: sessions, then changes, then builds. */ | |
| 540 | + | export function running(parts: RunningRow[][]): RunningRow[] { | |
| 541 | + | const order = { session: 0, change: 1, deploy: 2 } as const; | |
| 542 | + | return parts.flat().sort((a, b) => order[a.kind] - order[b.kind] || a.at - b.at || a.key.localeCompare(b.key)); | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | // --- Needs you ------------------------------------------------------------------ | |
| 546 | + | ||
| 547 | + | /** | |
| 548 | + | * What a row is. Tiers, most pressing first: | |
| 549 | + | * 0. production is down to an older build (`deploy`), or the workspace's agents used up its agent budget (`limit`); | |
| 550 | + | * 1. agent work that has stopped and can't go on without you; | |
| 551 | + | * 2. finished work waiting only for you (merge, review, an invitation, an agent asking, a request to add something); | |
| 552 | + | * 3. something stuck that may need a look; | |
| 553 | + | * 4. an unread notification that is a warning or a failure; | |
| 554 | + | * 5. a mention or a direct message. | |
| 555 | + | */ | |
| 556 | + | export type AttentionKind = | |
| 557 | + | | "limit" | |
| 558 | + | | "deploy" | |
| 559 | + | | "session_cap" | |
| 560 | + | | "agent_budget" | |
| 561 | + | | "pull_blocked" | |
| 562 | + | | "ready" | |
| 563 | + | | "review" | |
| 564 | + | | "invitation" | |
| 565 | + | | "install_request" | |
| 566 | + | | "agent_waiting" | |
| 567 | + | | "stuck" | |
| 568 | + | | "runner" | |
| 569 | + | | "notification" | |
| 570 | + | | "chat"; | |
| 571 | + | ||
| 572 | + | export const TIER: Record<AttentionKind, number> = { | |
| 573 | + | limit: 0, | |
| 574 | + | deploy: 0, | |
| 575 | + | session_cap: 1, | |
| 576 | + | agent_budget: 1, | |
| 577 | + | pull_blocked: 1, | |
| 578 | + | ready: 2, | |
| 579 | + | review: 2, | |
| 580 | + | invitation: 2, | |
| 581 | + | install_request: 2, | |
| 582 | + | agent_waiting: 2, | |
| 583 | + | stuck: 3, | |
| 584 | + | runner: 3, | |
| 585 | + | notification: 4, | |
| 586 | + | chat: 5, | |
| 587 | + | }; | |
| 588 | + | ||
| 589 | + | export type Stake = { text: string; tone: "danger" | "warn" | "accent" | null }; | |
| 590 | + | ||
| 591 | + | /** One row of Needs you. */ | |
| 592 | + | export type AttentionRow = { | |
| 593 | + | key: string; | |
| 594 | + | kind: AttentionKind; | |
| 595 | + | title: string; | |
| 596 | + | /** One line: what it is and where. */ | |
| 597 | + | detail: string; | |
| 598 | + | /** What is at stake, in a few words. */ | |
| 599 | + | stake: Stake | null; | |
| 600 | + | /** The action, right there. */ | |
| 601 | + | action: { label: string; to: string }; | |
| 602 | + | /** Who is on it: the agent, or the person who asked. */ | |
| 603 | + | owner: { name: string; agent: boolean } | null; | |
| 604 | + | /** Epoch ms: when it started waiting. */ | |
| 605 | + | at: number; | |
| 606 | + | /** Where it came from, for a member without Code access never `code`. */ | |
| 607 | + | from: "code" | "agents" | "notifications" | "chat" | "marketplace"; | |
| 608 | + | }; | |
| 609 | + | ||
| 610 | + | /** | |
| 611 | + | * Most pressing first: by tier, then what has waited longest, then by key | |
| 612 | + | * so the order never depends on the order rows were read. One of each key, | |
| 613 | + | * and one of each place: a notification about a pull request already on | |
| 614 | + | * the list is left out. | |
| 615 | + | */ | |
| 616 | + | export function rankAttention(rows: AttentionRow[]): AttentionRow[] { | |
| 617 | + | const sorted = [...rows].sort((a, b) => TIER[a.kind] - TIER[b.kind] || a.at - b.at || a.key.localeCompare(b.key)); | |
| 618 | + | const keys = new Set<string>(); | |
| 619 | + | const places = new Set<string>(); | |
| 620 | + | return sorted.filter((row) => { | |
| 621 | + | const place = row.action.to.split(/[?#]/)[0]; | |
| 622 | + | if (keys.has(row.key) || (row.kind === "notification" && places.has(place))) return false; | |
| 623 | + | keys.add(row.key); | |
| 624 | + | places.add(place); | |
| 625 | + | return true; | |
| 626 | + | }); | |
| 627 | + | } | |
| 628 | + | ||
| 629 | + | /** Start here: the first row by `rankAttention`, with why it was picked. */ | |
| 630 | + | export function startHere(ranked: AttentionRow[]): { row: AttentionRow; why: string } | null { | |
| 631 | + | const row = ranked[0]; | |
| 632 | + | if (!row) return null; | |
| 633 | + | const sameTier = ranked.filter((other) => TIER[other.kind] === TIER[row.kind]).length; | |
| 634 | + | const tail = sameTier > 1 ? ` Of the ${sameTier} like it, it has waited longest.` : ""; | |
| 635 | + | return { row, why: `${WHY[row.kind]}${tail}` }; | |
| 636 | + | } | |
| 637 | + | ||
| 638 | + | const WHY: Record<AttentionKind, string> = { | |
| 639 | + | limit: "Agents start nothing new past the workspace's agent budget, so everything else waits on it.", | |
| 640 | + | install_request: "A member asked for it, and only an owner can add it or turn it down.", | |
| 641 | + | deploy: "Production is serving an older build until this is fixed, so it comes before anything else.", | |
| 642 | + | session_cap: "An agent stopped at its spend cap and does nothing more until someone approves more.", | |
| 643 | + | agent_budget: "An agent used up its budget and takes no new work until it is raised.", | |
| 644 | + | pull_blocked: "An agent stopped on this change and won't go on until a person decides.", | |
| 645 | + | ready: "The work is done and only waits for you to land it.", | |
| 646 | + | review: "You were asked by name, so it waits for your verdict.", | |
| 647 | + | invitation: "It is addressed to you; no one else can answer it.", | |
| 648 | + | agent_waiting: "An agent is waiting on you to go on.", | |
| 649 | + | stuck: "A running agent has stopped reporting and may be stuck.", | |
| 650 | + | runner: "A job is waiting for a runner that is not there.", | |
| 651 | + | notification: "It is the most pressing unread notification.", | |
| 652 | + | chat: "Nothing else is waiting, and this is the oldest message for you.", | |
| 653 | + | }; | |
| 654 | + | ||
| 655 | + | // --- Rows from each source ---------------------------------------------------- | |
| 656 | + | ||
| 657 | + | /** Mission control's need row, as Home reads it (lib/mission-control.ts `NeedRow`). */ | |
| 658 | + | export type CodeNeed = { | |
| 659 | + | key: string; | |
| 660 | + | reason: string; | |
| 661 | + | repo: RepoPath | null; | |
| 662 | + | ref: string | null; | |
| 663 | + | title: string; | |
| 664 | + | ask: string; | |
| 665 | + | by: { name: string; agent: boolean } | null; | |
| 666 | + | for: string | null; | |
| 667 | + | at: number; | |
| 668 | + | to: string; | |
| 669 | + | facts: { label: string; value: string; tone: string | null }[]; | |
| 670 | + | }; | |
| 671 | + | ||
| 672 | + | const fact = (need: CodeNeed, label: string) => need.facts.find((f) => f.label === label)?.value ?? null; | |
| 673 | + | ||
| 674 | + | const REASON_STAKE: Record<string, Stake> = { | |
| 675 | + | blocking: { text: "Blocking", tone: "danger" }, | |
| 676 | + | checks_failing: { text: "Checks failing", tone: "danger" }, | |
| 677 | + | outside_guardrails: { text: "Over a guardrail", tone: "warn" }, | |
| 678 | + | low_confidence: { text: "Low confidence", tone: "warn" }, | |
| 679 | + | needs_review: { text: "Needs a verdict", tone: "warn" }, | |
| 680 | + | stalled: { text: "Stalled", tone: "warn" }, | |
| 681 | + | }; | |
| 682 | + | ||
| 683 | + | /** A Code need (failed deploy, pull request, review, invitation, stuck run, runner) as a row. */ | |
| 684 | + | export function codeRow(need: CodeNeed): AttentionRow { | |
| 685 | + | const prefix = need.key.split(":")[0]; | |
| 686 | + | const where = need.repo ? `${need.repo.namespace}/${need.repo.name}${need.ref ? ` ${need.ref}` : ""}` : null; | |
| 687 | + | const detail = where ? `${where} · ${need.ask}` : need.ask; | |
| 688 | + | const owner = need.by; | |
| 689 | + | const base = { key: need.key, title: need.title, detail, owner, at: need.at, from: "code" as const }; | |
| 690 | + | switch (prefix) { | |
| 691 | + | case "deploy": | |
| 692 | + | return { | |
| 693 | + | ...base, | |
| 694 | + | kind: "deploy", | |
| 695 | + | stake: { text: fact(need, "Production") === "Not live yet" ? "Not live yet" : "Production on an older build", tone: "danger" }, | |
| 696 | + | action: { label: "See the build", to: need.to }, | |
| 697 | + | }; | |
| 698 | + | case "invitation": | |
| 699 | + | return { ...base, kind: "invitation", stake: { text: `${fact(need, "Role") ?? "A"} role`, tone: null }, action: { label: "Respond", to: need.to } }; | |
| 700 | + | case "review": { | |
| 701 | + | const lines = fact(need, "Lines"); | |
| 702 | + | return { ...base, kind: "review", stake: { text: lines ?? "Your review", tone: null }, action: { label: "Review", to: need.to } }; | |
| 703 | + | } | |
| 704 | + | case "runner": | |
| 705 | + | return { ...base, kind: "runner", stake: { text: `Waiting ${fact(need, "Waiting") ?? ""}`.trim(), tone: "warn" }, action: { label: "Look", to: need.to } }; | |
| 706 | + | case "run": { | |
| 707 | + | const cost = fact(need, "Cost so far"); | |
| 708 | + | return { | |
| 709 | + | ...base, | |
| 710 | + | kind: "stuck", | |
| 711 | + | stake: { text: cost ? `${cost} so far` : `Quiet ${fact(need, "Quiet for") ?? ""}`.trim(), tone: "warn" }, | |
| 712 | + | action: { label: "Look", to: need.to }, | |
| 713 | + | }; | |
| 714 | + | } | |
| 715 | + | default: { | |
| 716 | + | if (need.reason === "ready_to_merge") { | |
| 717 | + | const files = fact(need, "Files changed"); | |
| 718 | + | return { | |
| 719 | + | ...base, | |
| 720 | + | kind: "ready", | |
| 721 | + | stake: { text: files ? `${files} ${files === "1" ? "file" : "files"} to land` : "Ready to land", tone: "accent" }, | |
| 722 | + | action: { label: "Merge", to: need.to }, | |
| 723 | + | }; | |
| 724 | + | } | |
| 725 | + | return { | |
| 726 | + | ...base, | |
| 727 | + | kind: "pull_blocked", | |
| 728 | + | stake: REASON_STAKE[need.reason] ?? { text: "Waiting on you", tone: "warn" }, | |
| 729 | + | action: { label: need.reason === "low_confidence" || need.reason === "needs_review" ? "Review" : "Decide", to: need.to }, | |
| 730 | + | }; | |
| 731 | + | } | |
| 732 | + | } | |
| 733 | + | } | |
| 734 | + | ||
| 735 | + | /** "$4.20", or "<$0.01" for a sliver. */ | |
| 736 | + | export function dollars(micros: number): string { | |
| 737 | + | const value = micros / 1_000_000; | |
| 738 | + | if (value > 0 && value < 0.01) return "<$0.01"; | |
| 739 | + | return `$${value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; | |
| 740 | + | } | |
| 741 | + | ||
| 742 | + | /** A session stopped at its spend cap that the viewer may approve more for. */ | |
| 743 | + | export function sessionCapRow(session: AgentSession, slug: string): AttentionRow { | |
| 744 | + | const cap = session.cap_micros; | |
| 745 | + | return { | |
| 746 | + | key: `session:${session.id}`, | |
| 747 | + | kind: "session_cap", | |
| 748 | + | title: session.visible ? session.title : `A session of @${session.agent_handle}`, | |
| 749 | + | detail: `@${session.agent_handle} stopped at its spend cap${session.channel_name ? ` in #${session.channel_name}` : ""}.`, | |
| 750 | + | stake: { text: cap != null ? `${dollars(session.charged_micros)} of ${dollars(cap)} cap` : `${dollars(session.charged_micros)} spent`, tone: "warn" }, | |
| 751 | + | action: { label: "Approve more", to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}` }, | |
| 752 | + | owner: { name: session.agent_handle, agent: true }, | |
| 753 | + | at: Date.parse(session.updated_at), | |
| 754 | + | from: "agents", | |
| 755 | + | }; | |
| 756 | + | } | |
| 757 | + | ||
| 758 | + | export type AgentLike = { | |
| 759 | + | id: string; | |
| 760 | + | handle: string; | |
| 761 | + | display_name: string; | |
| 762 | + | status: string; | |
| 763 | + | spent_month_micros: number; | |
| 764 | + | budget: { monthly_micros: number | null }; | |
| 765 | + | updated_at: string; | |
| 766 | + | }; | |
| 767 | + | ||
| 768 | + | /** An agent out of budget (for those who may raise it), or waiting on someone. */ | |
| 769 | + | export function agentRow(agent: AgentLike, slug: string): AttentionRow | null { | |
| 770 | + | const base = { | |
| 771 | + | key: `agent:${agent.id}`, | |
| 772 | + | owner: { name: agent.handle, agent: true }, | |
| 773 | + | at: Date.parse(agent.updated_at), | |
| 774 | + | from: "agents" as const, | |
| 775 | + | }; | |
| 776 | + | if (agent.status === "out_of_budget") { | |
| 777 | + | const budget = agent.budget.monthly_micros; | |
| 778 | + | return { | |
| 779 | + | ...base, | |
| 780 | + | kind: "agent_budget", | |
| 781 | + | title: `${agent.display_name} is out of budget`, | |
| 782 | + | detail: `@${agent.handle} takes no new work this month until its budget is raised.`, | |
| 783 | + | stake: { text: budget != null ? `${dollars(Math.max(0, budget - agent.spent_month_micros))} left` : "Budget used", tone: "danger" }, | |
| 784 | + | action: { label: "Raise the budget", to: `/${slug}/-/agents/${agent.handle}/spend` }, | |
| 785 | + | }; | |
| 786 | + | } | |
| 787 | + | if (agent.status === "waiting") { | |
| 788 | + | return { | |
| 789 | + | ...base, | |
| 790 | + | kind: "agent_waiting", | |
| 791 | + | title: `${agent.display_name} is waiting`, | |
| 792 | + | detail: `@${agent.handle} is waiting before it goes on.`, | |
| 793 | + | stake: null, | |
| 794 | + | action: { label: "Open", to: `/${slug}/-/agents/${agent.handle}` }, | |
| 795 | + | }; | |
| 796 | + | } | |
| 797 | + | return null; | |
| 798 | + | } | |
| 799 | + | ||
| 800 | + | const NOTIFICATION_STAKE: Record<string, string> = { | |
| 801 | + | agent: "Agent waiting", | |
| 802 | + | review_requested: "Review requested", | |
| 803 | + | assign: "Assigned to you", | |
| 804 | + | mention: "Mentioned", | |
| 805 | + | team_mention: "Team mentioned", | |
| 806 | + | ci_activity: "CI activity", | |
| 807 | + | security_alert: "Security alert", | |
| 808 | + | state_change: "State changed", | |
| 809 | + | author: "Your work", | |
| 810 | + | comment: "Comment", | |
| 811 | + | manual: "Subscribed", | |
| 812 | + | subscribed: "Watching", | |
| 813 | + | }; | |
| 814 | + | ||
| 815 | + | /** | |
| 816 | + | * An unread notification that is a warning (someone or something is | |
| 817 | + | * waiting) or a failure, in this workspace. A member without Code access | |
| 818 | + | * gets none about a repository. | |
| 819 | + | */ | |
| 820 | + | export function notificationRow(item: InboxItem, slug: string, code: boolean): AttentionRow | null { | |
| 821 | + | if (item.readAt != null || item.doneAt != null) return null; | |
| 822 | + | if (item.severity !== "warning" && item.severity !== "error") return null; | |
| 823 | + | const inWorkspace = (item.workspace ?? item.repo?.split("/")[0] ?? "").toLowerCase() === slug; | |
| 824 | + | if (!inWorkspace) return null; | |
| 825 | + | if (!code && item.repo) return null; | |
| 826 | + | return { | |
| 827 | + | key: `notification:${item.id}`, | |
| 828 | + | kind: "notification", | |
| 829 | + | title: item.body || item.title, | |
| 830 | + | detail: item.body ? item.title : (item.repo ?? "A notification"), | |
| 831 | + | stake: { text: NOTIFICATION_STAKE[item.reason] ?? "Notification", tone: item.severity === "error" ? "danger" : "warn" }, | |
| 832 | + | action: { label: "Open", to: item.url }, | |
| 833 | + | owner: item.actor ? { name: item.actor, agent: item.actor === "g1t" || item.actor.endsWith("-agent") } : null, | |
| 834 | + | at: Date.parse(item.updatedAt), | |
| 835 | + | from: "notifications", | |
| 836 | + | }; | |
| 837 | + | } | |
| 838 | + | ||
| 839 | + | export type ChatEntryLike = { | |
| 840 | + | channel: { id: string; kind: "channel" | "dm"; name: string | null; last_message_at?: string | null }; | |
| 841 | + | title: string; | |
| 842 | + | muted: boolean; | |
| 843 | + | unread: number; | |
| 844 | + | mentions: number; | |
| 845 | + | }; | |
| 846 | + | ||
| 847 | + | /** A conversation with a mention of you, or a direct message you haven't read. */ | |
| 848 | + | export function chatRow(entry: ChatEntryLike, slug: string): AttentionRow | null { | |
| 849 | + | if (entry.muted) return null; | |
| 850 | + | if (!(entry.mentions > 0 || (entry.channel.kind === "dm" && entry.unread > 0))) return null; | |
| 851 | + | const to = entry.channel.kind === "dm" || !entry.channel.name ? `/${slug}/-/chat/dm/${entry.channel.id}` : `/${slug}/-/chat/${entry.channel.name}`; | |
| 852 | + | const dm = entry.channel.kind === "dm"; | |
| 853 | + | return { | |
| 854 | + | key: `chat:${entry.channel.id}`, | |
| 855 | + | kind: "chat", | |
| 856 | + | title: dm ? entry.title : `#${entry.title}`, | |
| 857 | + | detail: dm ? "A direct message you haven't read." : "You were mentioned.", | |
| 858 | + | stake: { | |
| 859 | + | text: entry.mentions > 0 ? `${entry.mentions} ${entry.mentions === 1 ? "mention" : "mentions"}` : `${entry.unread} unread`, | |
| 860 | + | tone: entry.mentions > 0 ? "accent" : null, | |
| 861 | + | }, | |
| 862 | + | action: { label: "Reply", to }, | |
| 863 | + | owner: null, | |
| 864 | + | at: entry.channel.last_message_at ? Date.parse(entry.channel.last_message_at) : 0, | |
| 865 | + | from: "chat", | |
| 866 | + | }; | |
| 867 | + | } | |
| 868 | + | ||
| 869 | + | /** A member's open request to add something from the Marketplace, for an owner. */ | |
| 870 | + | export function installRequestRow(request: InstallRequest, slug: string): AttentionRow | null { | |
| 871 | + | if (request.status !== "open") return null; | |
| 872 | + | return { | |
| 873 | + | key: `request:${request.id}`, | |
| 874 | + | kind: "install_request", | |
| 875 | + | title: `${request.requested_by} asked to add ${request.name}`, | |
| 876 | + | detail: request.note ? `“${request.note}”` : "A request to add it from the Marketplace.", | |
| 877 | + | stake: { text: "Only owners can add it", tone: null }, | |
| 878 | + | action: { label: "Review the request", to: `/${slug}/-/marketplace/requests` }, | |
| 879 | + | owner: { name: request.requested_by, agent: false }, | |
| 880 | + | at: Date.parse(request.requested_at), | |
| 881 | + | from: "marketplace", | |
| 882 | + | }; | |
| 883 | + | } | |
| 884 | + | ||
| 885 | + | /** | |
| 886 | + | * The workspace's agents at 100% of its monthly agent budget, for those who | |
| 887 | + | * may raise it. `since` is when it was reached as far as is known: the | |
| 888 | + | * newest session that stopped at its cap, else the start of the month. | |
| 889 | + | */ | |
| 890 | + | export function limitRow(input: { alert: number | null; spentMicros: number; since: number }, slug: string): AttentionRow | null { | |
| 891 | + | if (input.alert == null || input.alert < 100) return null; | |
| 892 | + | return { | |
| 893 | + | key: "limit:agents", | |
| 894 | + | kind: "limit", | |
| 895 | + | title: "Agents used up the workspace's budget", | |
| 896 | + | detail: `${dollars(input.spentMicros)} spent this month. Agents start nothing new until the budget is raised or the month turns.`, | |
| 897 | + | stake: { text: "No new agent work", tone: "danger" }, | |
| 898 | + | action: { label: "Raise the budget", to: `/${slug}/-/spend` }, | |
| 899 | + | owner: null, | |
| 900 | + | at: input.since, | |
| 901 | + | from: "agents", | |
| 902 | + | }; | |
| 903 | + | } | |
| 904 | + | ||
| 905 | + | /** What Needs you and Start here show, and which sources did not answer. */ | |
| 906 | + | export type Attention = { | |
| 907 | + | rows: AttentionRow[]; | |
| 908 | + | start: { row: AttentionRow; why: string } | null; | |
| 909 | + | /** Sources that did not answer, by name, for the card to say so. */ | |
| 910 | + | missing: string[]; | |
| 911 | + | }; | |
| 912 | + | ||
| 913 | + | export function attention(input: { | |
| 914 | + | slug: string; | |
| 915 | + | code: boolean; | |
| 916 | + | codeNeeds: CodeNeed[] | null; | |
| 917 | + | capped: AgentSession[] | null; | |
| 918 | + | agents: AgentLike[] | null; | |
| 919 | + | canManage: boolean; | |
| 920 | + | /** The workspace's agent budget, for those who may manage agents; null for anyone else. */ | |
| 921 | + | limit?: { alert: number | null; spentMicros: number; since: number } | null; | |
| 922 | + | /** Install requests, when the viewer may answer them (an owner); null otherwise or when they couldn't be read. */ | |
| 923 | + | requests?: InstallRequest[] | null; | |
| 924 | + | notifications: InboxItem[] | null; | |
| 925 | + | chat: ChatEntryLike[] | null; | |
| 926 | + | }): Attention { | |
| 927 | + | const rows: AttentionRow[] = []; | |
| 928 | + | const missing: string[] = []; | |
| 929 | + | if (input.code) { | |
| 930 | + | if (input.codeNeeds) rows.push(...input.codeNeeds.map(codeRow)); | |
| 931 | + | else missing.push("Code"); | |
| 932 | + | } | |
| 933 | + | if (input.limit && input.canManage) { | |
| 934 | + | const row = limitRow(input.limit, input.slug); | |
| 935 | + | if (row) rows.push(row); | |
| 936 | + | } | |
| 937 | + | for (const request of input.requests ?? []) { | |
| 938 | + | const row = installRequestRow(request, input.slug); | |
| 939 | + | if (row) rows.push(row); | |
| 940 | + | } | |
| 941 | + | if (input.capped) rows.push(...input.capped.map((session) => sessionCapRow(session, input.slug))); | |
| 942 | + | if (input.agents) { | |
| 943 | + | const cappedAgents = new Set((input.capped ?? []).map((s) => s.agent_handle)); | |
| 944 | + | for (const agent of input.agents) { | |
| 945 | + | if (agent.status === "out_of_budget" && !input.canManage) continue; | |
| 946 | + | if (agent.status === "waiting" && cappedAgents.has(agent.handle)) continue; | |
| 947 | + | const row = agentRow(agent, input.slug); | |
| 948 | + | if (row) rows.push(row); | |
| 949 | + | } | |
| 950 | + | } | |
| 951 | + | if (input.capped == null || input.agents == null) missing.push("Agents"); | |
| 952 | + | if (input.notifications) { | |
| 953 | + | for (const item of input.notifications) { | |
| 954 | + | const row = notificationRow(item, input.slug, input.code); | |
| 955 | + | if (row) rows.push(row); | |
| 956 | + | } | |
| 957 | + | } else missing.push("Notifications"); | |
| 958 | + | if (input.chat) { | |
| 959 | + | for (const entry of input.chat) { | |
| 960 | + | const row = chatRow(entry, input.slug); | |
| 961 | + | if (row) rows.push(row); | |
| 962 | + | } | |
| 963 | + | } else missing.push("Chat"); | |
| 964 | + | const ranked = rankAttention(rows); | |
| 965 | + | return { rows: ranked, start: startHere(ranked), missing }; | |
| 966 | + | } | |
| 967 | + | ||
| 968 | + | // --- Spend in the span ---------------------------------------------------------- | |
| 969 | + | ||
| 970 | + | /** Money in, which the statement lists but which is not spend. */ | |
| 971 | + | const MONEY_IN: ReadonlySet<string> = new Set(["Payments", "AI credit", "Credits from g1t", "Refunds", "Tax", "Card processing fees"]); | |
| 972 | + | ||
| 973 | + | export type Spend = { | |
| 974 | + | /** The statement days counted, `YYYY-MM-DD` in UTC, first and last. */ | |
| 975 | + | from: string; | |
| 976 | + | to: string; | |
| 977 | + | /** Usage at price over those days, in micros. */ | |
| 978 | + | totalMicros: number; | |
| 979 | + | /** A line per kind of charge, as the statement names it, most first. */ | |
| 980 | + | lines: { kind: string; micros: number; count: number }[]; | |
| 981 | + | /** Usage at price this month so far. */ | |
| 982 | + | monthMicros: number; | |
| 983 | + | }; | |
| 984 | + | ||
| 985 | + | /** The UTC months (`YYYY-MM`) the span touches, oldest first: the statements Home reads. */ | |
| 986 | + | export function spanMonths(span: Pick<Span, "from" | "now">): string[] { | |
| 987 | + | const months: string[] = []; | |
| 988 | + | const first = new Date(span.from); | |
| 989 | + | let year = first.getUTCFullYear(); | |
| 990 | + | let month = first.getUTCMonth(); | |
| 991 | + | const last = new Date(span.now).toISOString().slice(0, 7); | |
| 992 | + | for (let i = 0; i < 3; i++) { | |
| 993 | + | const key = `${year}-${String(month + 1).padStart(2, "0")}`; | |
| 994 | + | months.push(key); | |
| 995 | + | if (key >= last) break; | |
| 996 | + | month++; | |
| 997 | + | if (month === 12) { | |
| 998 | + | month = 0; | |
| 999 | + | year++; | |
| 1000 | + | } | |
| 1001 | + | } | |
| 1002 | + | return months; | |
| 1003 | + | } | |
| 1004 | + | ||
| 1005 | + | /** | |
| 1006 | + | * The span's spend from the statements grouped by day: each usage line at | |
| 1007 | + | * its price (what was charged, plus what the plan, a trial, a pool or a | |
| 1008 | + | * discount paid of it), leaving out money in. The statement keeps whole | |
| 1009 | + | * UTC days, so the span is counted from the start of the UTC day it began. | |
| 1010 | + | * `statements` is the months of `spanMonths`, the current one last. | |
| 1011 | + | */ | |
| 1012 | + | export function spendIn(statements: Pick<Statement, "groups" | "totals">[], span: Pick<Span, "from" | "now">): Spend { | |
| 1013 | + | const from = new Date(span.from).toISOString().slice(0, 10); | |
| 1014 | + | const to = new Date(span.now).toISOString().slice(0, 10); | |
| 1015 | + | const byKind = new Map<string, { micros: number; count: number }>(); | |
| 1016 | + | for (const statement of statements) { | |
| 1017 | + | for (const group of statement.groups) { | |
| 1018 | + | if (group.key < from || group.key > to) continue; | |
| 1019 | + | for (const line of group.lines) { | |
| 1020 | + | if (MONEY_IN.has(line.kind)) continue; | |
| 1021 | + | const micros = line.priceMicros ?? line.chargedMicros; | |
| 1022 | + | const kept = byKind.get(line.kind) ?? { micros: 0, count: 0 }; | |
| 1023 | + | byKind.set(line.kind, { micros: kept.micros + micros, count: kept.count + line.count }); | |
| 1024 | + | } | |
| 1025 | + | } | |
| 1026 | + | } | |
| 1027 | + | const lines = [...byKind] | |
| 1028 | + | .map(([kind, value]) => ({ kind, ...value })) | |
| 1029 | + | .filter((line) => line.micros > 0) | |
| 1030 | + | .sort((a, b) => b.micros - a.micros || a.kind.localeCompare(b.kind)); | |
| 1031 | + | const current = statements.at(-1); | |
| 1032 | + | return { | |
| 1033 | + | from, | |
| 1034 | + | to, | |
| 1035 | + | totalMicros: lines.reduce((sum, line) => sum + line.micros, 0), | |
| 1036 | + | lines, | |
| 1037 | + | monthMicros: current ? (current.totals.priceMicros ?? current.totals.chargedMicros) : 0, | |
| 1038 | + | }; | |
| 1039 | + | } | |
| 1040 | + | ||
| 1041 | + | // --- The sentence ----------------------------------------------------------- | |
| 1042 | + | ||
| 1043 | + | /** | |
| 1044 | + | * The sentence under the heading: "Since Tuesday evening, agents finished | |
| 1045 | + | * 35 tasks and 12 changes landed. 4 needed a fix after review." Finished | |
| 1046 | + | * counts every task that settled well: accepted, fixed, and sessions that | |
| 1047 | + | * finished. | |
| 1048 | + | */ | |
| 1049 | + | export function spanSentence(span: Pick<Span, "words">, work: Pick<SpanWork, "counts" | "tasks"> | null, landed: number | null): string { | |
| 1050 | + | const lead = span.words; | |
| 1051 | + | if (!work) return `${lead}, agent work couldn't be read.`; | |
| 1052 | + | const { accepted, fixed, finished, dropped } = work.counts; | |
| 1053 | + | const done = accepted + fixed + finished; | |
| 1054 | + | const changes = landed && landed > 0 ? `${plural(landed, "change")} landed` : null; | |
| 1055 | + | if (work.tasks.length === 0 && !changes) return `${lead}, nothing new has settled.`; | |
| 1056 | + | const first = | |
| 1057 | + | done > 0 | |
| 1058 | + | ? `${lead}, agents finished ${plural(done, "task")}${changes ? ` and ${changes}` : ""}.` | |
| 1059 | + | : changes | |
| 1060 | + | ? `${lead}, ${changes}${work.tasks.length > 0 ? "; agents finished none of their tasks" : ""}.` | |
| 1061 | + | : `${lead}, agents finished no tasks.`; | |
| 1062 | + | const after: string[] = []; | |
| 1063 | + | if (fixed > 0) after.push(`${fixed.toLocaleString("en-US")} needed a fix after review`); | |
| 1064 | + | if (dropped > 0) after.push(`${dropped.toLocaleString("en-US")} didn't finish`); | |
| 1065 | + | if (after.length === 0) return first; | |
| 1066 | + | const text = after.length > 1 ? `${after[0]}, and ${after[1]}` : after[0]!; | |
| 1067 | + | return `${first} ${text.charAt(0).toUpperCase()}${text.slice(1)}.`; | |
| 1068 | + | } | |
| 1069 | + | ||
| 1070 | + | /** "8 things need you.", or that nothing does. */ | |
| 1071 | + | export function waitingSentence(total: number): string { | |
| 1072 | + | if (total === 0) return "Nothing needs you right now."; | |
| 1073 | + | return `${plural(total, "thing")} ${total === 1 ? "needs" : "need"} you.`; | |
| 1074 | + | } | |
| 1075 | + | ||
| 1076 | + | /** How long something has waited: "45 min", "17 h", "2 d". */ | |
| 1077 | + | export function waited(at: number, now: number): string { | |
| 1078 | + | const minutes = Math.max(0, Math.floor((now - at) / MINUTE)); | |
| 1079 | + | if (minutes < 60) return `${minutes} min`; | |
| 1080 | + | if (minutes < 24 * 60) return `${Math.floor(minutes / 60)} h`; | |
| 1081 | + | return `${Math.floor(minutes / (24 * 60))} d`; | |
| 1082 | + | } | |
| 1083 | + | ||
| 1084 | + | /** "3:40 PM" when it is the same day as `now`, else "Oct 7". */ | |
| 1085 | + | export function whenShort(at: number, now: number, timeZone: string | null): string { | |
| 1086 | + | if (dayIn(at, timeZone) === dayIn(now, timeZone)) return formatIn(at, timeZone, { hour: "numeric", minute: "2-digit" }); | |
| 1087 | + | return now - at < 6 * DAY ? formatIn(at, timeZone, { weekday: "short", hour: "numeric" }) : shortDate(at, timeZone); | |
| 1088 | + | } |
| 119 | 119 | "user_for_session user_for_access_token usernames waiting_workspaces workflows workspace workspace_invites github_enabled " + | |
| 120 | 120 | "stars about public_links branch_drift tags last_commits languages contributors license releases release " + | |
| 121 | 121 | "stargazers starred commit_checks shortcuts spend person_budgets usage_report templates install_requests extension_installs " + | |
| 122 | − | "people_directory team_agents team_context team_members github_visible_installations skill_library skill agent_skills" | |
| 122 | + | "people_directory team_agents team_context team_members github_visible_installations skill_library skill agent_skills last_visit" | |
| 123 | 123 | ).split(" "), | |
| 124 | 124 | ); | |
| 125 | 125 |
| 1 | − | /** | |
| 2 | − | * Today's reads: each section's data from its own service, every call on | |
| 3 | − | * its own so one that fails leaves only its section saying so. The shaping | |
| 4 | − | * is in ./today.ts. | |
| 5 | − | */ | |
| 6 | − | import type { AgentSession, Repo, User } from "@g1t/contracts"; | |
| 7 | − | ||
| 8 | − | import { type CodePull, type CodeWork, type SessionWork, type Spend, dayIn, pullKey, spendToday } from "./today"; | |
| 9 | − | import { agents, billing, repos, work, workspaceAgents } from "./services.server"; | |
| 10 | − | ||
| 11 | − | /** Pull requests read per project and list: `LIST_PAGE` in services/work. */ | |
| 12 | − | const PULL_PAGE = 100; | |
| 13 | − | /** The most projects whose pull requests are read. */ | |
| 14 | − | const MAX_PROJECTS = 50; | |
| 15 | − | /** Revise runs and sessions read: the services' most in one list. */ | |
| 16 | − | const RUN_PAGE = 200; | |
| 17 | − | const SESSION_PAGE = 200; | |
| 18 | − | const DAY = 24 * 60 * 60 * 1000; | |
| 19 | − | ||
| 20 | − | const warn = (what: string) => (error: unknown) => { | |
| 21 | − | console.warn(`today: ${what} failed`, error); | |
| 22 | − | return null; | |
| 23 | − | }; | |
| 24 | − | ||
| 25 | − | /** | |
| 26 | − | * The agents' pull requests in the workspace's projects, open and recently | |
| 27 | − | * merged or closed, with how many times each was sent back to revise. | |
| 28 | − | * Null when Code did not answer. | |
| 29 | − | */ | |
| 30 | − | export async function loadCodeWork(viewer: User, slug: string, now: number): Promise<CodeWork | null> { | |
| 31 | − | const [list, runs] = await Promise.all([ | |
| 32 | − | repos.list(viewer, { namespace: slug }).catch(warn("repos")), | |
| 33 | − | agents.listRuns(viewer, { workspace: slug, kind: "revise", limit: RUN_PAGE }).catch(warn("revise runs")), | |
| 34 | − | ]); | |
| 35 | − | if (!list || !runs?.ok) return null; | |
| 36 | − | const chosen = list.filter((repo) => !repo.forkOf && repo.namespace.toLowerCase() === slug).slice(0, MAX_PROJECTS); | |
| 37 | − | const byId = new Map<string, Repo>(chosen.map((repo) => [repo.id, repo])); | |
| 38 | − | const batch = chosen.length > 0 ? await work.pullsForRepos([...byId.keys()], viewer, PULL_PAGE).catch(warn("pull requests")) : []; | |
| 39 | − | if (!batch) return null; | |
| 40 | − | const pulls: CodePull[] = []; | |
| 41 | − | const since = now - 8 * DAY; | |
| 42 | − | let complete = list.filter((repo) => !repo.forkOf && repo.namespace.toLowerCase() === slug).length <= MAX_PROJECTS; | |
| 43 | − | for (const entry of batch) { | |
| 44 | − | const repo = byId.get(entry.repoId); | |
| 45 | − | if (!repo) continue; | |
| 46 | − | const path = { namespace: repo.namespace, name: repo.name }; | |
| 47 | − | for (const pull of [...entry.open, ...entry.closed]) { | |
| 48 | − | pulls.push({ | |
| 49 | − | repo: path, | |
| 50 | − | number: pull.number, | |
| 51 | − | title: pull.title, | |
| 52 | − | status: pull.status, | |
| 53 | − | mergedAt: pull.mergedAt, | |
| 54 | − | createdAt: pull.createdAt, | |
| 55 | − | updatedAt: pull.updatedAt, | |
| 56 | − | author: { username: pull.author.username, kind: pull.author.kind }, | |
| 57 | − | }); | |
| 58 | − | } | |
| 59 | − | // A full page that does not reach back eight days may leave some out. | |
| 60 | − | for (const list of [entry.open, entry.closed]) { | |
| 61 | − | if (list.length >= PULL_PAGE) { | |
| 62 | − | const oldest = Math.min(...list.map((pull) => Date.parse(pull.mergedAt ?? pull.updatedAt))); | |
| 63 | − | if (oldest > since) complete = false; | |
| 64 | − | } | |
| 65 | − | } | |
| 66 | − | } | |
| 67 | − | const revisions: Record<string, number> = {}; | |
| 68 | − | for (const run of runs.value) { | |
| 69 | − | if (run.number == null) continue; | |
| 70 | − | const key = pullKey(run.repo, run.number); | |
| 71 | − | revisions[key] = (revisions[key] ?? 0) + 1; | |
| 72 | − | } | |
| 73 | − | if (runs.value.length >= RUN_PAGE && Date.parse(runs.value[runs.value.length - 1].createdAt) > since) complete = false; | |
| 74 | − | return { pulls, revisions, complete }; | |
| 75 | − | } | |
| 76 | − | ||
| 77 | − | /** The workspace's newest sessions; `complete` when the list reaches back before today. */ | |
| 78 | − | export async function loadSessions(viewer: User, slug: string, now: number, timeZone: string | null): Promise<SessionWork | null> { | |
| 79 | − | const listed = await workspaceAgents.sessions(slug, viewer, { limit: SESSION_PAGE }).catch(warn("sessions")); | |
| 80 | − | if (!listed?.ok) return null; | |
| 81 | − | const sessions: AgentSession[] = listed.value; | |
| 82 | − | const oldest = sessions.at(-1); | |
| 83 | − | const complete = sessions.length < SESSION_PAGE || (oldest != null && dayIn(Date.parse(oldest.created_at), timeZone) !== dayIn(now, timeZone)); | |
| 84 | − | return { sessions, complete }; | |
| 85 | − | } | |
| 86 | − | ||
| 87 | − | /** Sessions stopped at their cap that the viewer may approve more for, and whether they may manage agents. */ | |
| 88 | − | export async function loadCapped(viewer: User, slug: string): Promise<{ capped: AgentSession[]; canManage: boolean } | null> { | |
| 89 | − | const overview = await workspaceAgents.overview(slug, viewer).catch(warn("agents overview")); | |
| 90 | − | if (!overview?.ok) return null; | |
| 91 | − | return { capped: overview.value.waiting_on_you, canManage: overview.value.can_manage }; | |
| 92 | − | } | |
| 93 | − | ||
| 94 | − | /** Today's spend from this month's statement. Null when billing did not answer. */ | |
| 95 | − | export async function loadSpend(viewer: User, slug: string, now: number): Promise<Spend | null> { | |
| 96 | − | const statement = await billing.statement(slug, viewer, null, "day").catch(warn("statement")); | |
| 97 | − | return statement?.ok ? spendToday(statement.value, now) : null; | |
| 98 | − | } |
| 1 | − | import assert from "node:assert/strict"; | |
| 2 | − | import { test } from "node:test"; | |
| 3 | − | ||
| 4 | − | import type { AgentSession, InboxItem } from "@g1t/contracts"; | |
| 5 | − | ||
| 6 | − | import { | |
| 7 | − | type AttentionRow, | |
| 8 | − | type CodeNeed, | |
| 9 | − | type CodePull, | |
| 10 | − | acceptance, | |
| 11 | − | agentRow, | |
| 12 | − | attention, | |
| 13 | − | chatRow, | |
| 14 | − | codeRow, | |
| 15 | − | dayIn, | |
| 16 | − | daySentence, | |
| 17 | − | dayWork, | |
| 18 | − | daysBefore, | |
| 19 | − | notificationRow, | |
| 20 | − | pullOutcome, | |
| 21 | − | rankAttention, | |
| 22 | − | sessionOutcome, | |
| 23 | − | spendToday, | |
| 24 | − | startHere, | |
| 25 | − | trendLabel, | |
| 26 | − | waitingSentence, | |
| 27 | − | } from "./today.ts"; | |
| 28 | − | ||
| 29 | − | const HOUR = 3_600_000; | |
| 30 | − | const DAY = 24 * HOUR; | |
| 31 | − | // Friday 2026-10-09, 18:00 UTC; 11:00 in Los Angeles. | |
| 32 | − | const NOW = Date.parse("2026-10-09T18:00:00Z"); | |
| 33 | − | const LA = "America/Los_Angeles"; | |
| 34 | − | const repo = { namespace: "acme", name: "web" }; | |
| 35 | − | const iso = (ms: number) => new Date(ms).toISOString(); | |
| 36 | − | ||
| 37 | − | function pull(over: Partial<CodePull> = {}): CodePull { | |
| 38 | − | return { | |
| 39 | − | repo, | |
| 40 | − | number: 1, | |
| 41 | − | title: "Fix the flaky test", | |
| 42 | − | status: "merged", | |
| 43 | − | mergedAt: iso(NOW - HOUR), | |
| 44 | − | createdAt: iso(NOW - 3 * HOUR), | |
| 45 | − | updatedAt: iso(NOW - HOUR), | |
| 46 | − | author: { username: "g1t", kind: "agent" }, | |
| 47 | − | ...over, | |
| 48 | − | }; | |
| 49 | − | } | |
| 50 | − | ||
| 51 | − | function session(over: Partial<AgentSession> = {}): AgentSession { | |
| 52 | − | return { | |
| 53 | − | id: "s1", | |
| 54 | − | workspace_id: "w", | |
| 55 | − | agent_id: "a", | |
| 56 | − | agent_handle: "margo", | |
| 57 | − | agent_name: "Margo", | |
| 58 | − | agent_avatar_seed: "margo", | |
| 59 | − | subagent: null, | |
| 60 | − | kind: "chat", | |
| 61 | − | parent_id: null, | |
| 62 | − | root_id: "s1", | |
| 63 | − | payer_agent_id: "a", | |
| 64 | − | title: "Draft the launch notes", | |
| 65 | − | goal: "", | |
| 66 | − | status: "done", | |
| 67 | − | status_note: null, | |
| 68 | − | summary: null, | |
| 69 | − | channel_id: "c", | |
| 70 | − | channel_kind: "channel", | |
| 71 | − | channel_name: "launch", | |
| 72 | − | card_message_id: null, | |
| 73 | − | asked_by: null, | |
| 74 | − | asked_by_username: "bo", | |
| 75 | − | routine_id: null, | |
| 76 | − | steps: 3, | |
| 77 | − | tool_calls: 2, | |
| 78 | − | input_tokens: 0, | |
| 79 | − | output_tokens: 0, | |
| 80 | − | charged_micros: 400_000, | |
| 81 | − | cap_micros: 2_000_000, | |
| 82 | − | model: null, | |
| 83 | − | outputs: [], | |
| 84 | − | created_at: iso(NOW - 2 * HOUR), | |
| 85 | − | updated_at: iso(NOW - HOUR), | |
| 86 | − | finished_at: iso(NOW - HOUR), | |
| 87 | − | visible: true, | |
| 88 | − | ...over, | |
| 89 | − | }; | |
| 90 | − | } | |
| 91 | − | ||
| 92 | − | test("today is the reader's day, and the seven days before it", () => { | |
| 93 | − | assert.equal(dayIn(NOW, LA), "2026-10-09"); | |
| 94 | − | assert.equal(dayIn(Date.parse("2026-10-10T02:00:00Z"), LA), "2026-10-09"); | |
| 95 | − | assert.equal(dayIn(Date.parse("2026-10-10T02:00:00Z"), null), "2026-10-10"); | |
| 96 | − | assert.equal(dayIn(NOW, "Not/AZone"), "2026-10-09"); | |
| 97 | − | assert.deepEqual(daysBefore(NOW, LA, 3), ["2026-10-08", "2026-10-07", "2026-10-06"]); | |
| 98 | − | }); | |
| 99 | − | ||
| 100 | − | test("an agent's pull request is accepted first time when it merged today with no revision", () => { | |
| 101 | − | assert.equal(pullOutcome(pull(), 0, "2026-10-09", null), "accepted"); | |
| 102 | − | assert.equal(pullOutcome(pull(), 2, "2026-10-09", null), "fixed"); | |
| 103 | − | assert.equal(pullOutcome(pull({ mergedAt: iso(NOW - DAY) }), 0, "2026-10-09", null), null); | |
| 104 | − | assert.equal(pullOutcome(pull({ status: "closed", mergedAt: null }), 0, "2026-10-09", null), "dropped"); | |
| 105 | − | assert.equal(pullOutcome(pull({ status: "open", mergedAt: null }), 1, "2026-10-09", null), "open"); | |
| 106 | − | assert.equal(pullOutcome(pull({ status: "draft", mergedAt: null, updatedAt: iso(NOW - 2 * DAY) }), 0, "2026-10-09", null), null); | |
| 107 | − | }); | |
| 108 | − | ||
| 109 | − | test("a session counts once, at the root of its tree, and is never 'accepted'", () => { | |
| 110 | − | assert.equal(sessionOutcome(session(), "2026-10-09", null), "finished"); | |
| 111 | − | assert.equal(sessionOutcome(session({ parent_id: "s0" }), "2026-10-09", null), null); | |
| 112 | − | assert.equal(sessionOutcome(session({ status: "failed" }), "2026-10-09", null), "dropped"); | |
| 113 | − | assert.equal(sessionOutcome(session({ status: "stopped" }), "2026-10-09", null), "dropped"); | |
| 114 | − | assert.equal(sessionOutcome(session({ status: "working", finished_at: null }), "2026-10-09", null), "open"); | |
| 115 | − | assert.equal(sessionOutcome(session({ status: "needs_approval", finished_at: null, updated_at: iso(NOW - 3 * DAY) }), "2026-10-09", null), null); | |
| 116 | − | assert.equal(sessionOutcome(session({ finished_at: iso(NOW - DAY) }), "2026-10-09", null), null); | |
| 117 | − | }); | |
| 118 | − | ||
| 119 | − | test("accepted first time is accepted over every agent pull request that settled", () => { | |
| 120 | − | assert.deepEqual(acceptance(["accepted", "accepted", "fixed", "dropped", "open", "finished"]), { value: 0.5, of: 4 }); | |
| 121 | − | assert.equal(acceptance(["open", "finished"]), null); | |
| 122 | − | }); | |
| 123 | − | ||
| 124 | − | test("the day's work: pull requests and sessions, sources, and the week before", () => { | |
| 125 | − | const work = dayWork({ | |
| 126 | − | now: NOW, | |
| 127 | − | timeZone: null, | |
| 128 | − | slug: "acme", | |
| 129 | − | code: { | |
| 130 | − | pulls: [ | |
| 131 | − | pull({ number: 1 }), | |
| 132 | − | pull({ number: 2 }), | |
| 133 | − | pull({ number: 3, status: "open", mergedAt: null }), | |
| 134 | − | // A person's pull request is not agent work. | |
| 135 | − | pull({ number: 4, author: { username: "bo", kind: "user" } }), | |
| 136 | − | // The week before: one accepted, one fixed. | |
| 137 | − | pull({ number: 5, mergedAt: iso(NOW - 2 * DAY), updatedAt: iso(NOW - 2 * DAY) }), | |
| 138 | − | pull({ number: 6, mergedAt: iso(NOW - 3 * DAY), updatedAt: iso(NOW - 3 * DAY) }), | |
| 139 | − | // Older than a week: not counted. | |
| 140 | − | pull({ number: 7, mergedAt: iso(NOW - 9 * DAY), updatedAt: iso(NOW - 9 * DAY) }), | |
| 141 | − | ], | |
| 142 | − | revisions: { "acme/web#2": 1, "acme/web#6": 2 }, | |
| 143 | − | complete: true, | |
| 144 | − | }, | |
| 145 | − | sessions: { sessions: [session(), session({ id: "s2", kind: "routine", status: "working", finished_at: null }), session({ id: "s3", parent_id: "s1" })], complete: true }, | |
| 146 | − | }); | |
| 147 | − | assert.equal(work.tasks.length, 5); | |
| 148 | − | assert.deepEqual(work.counts, { accepted: 1, fixed: 1, finished: 1, open: 2, dropped: 0 }); | |
| 149 | − | assert.deepEqual(work.rate, { value: 0.5, of: 2 }); | |
| 150 | − | assert.deepEqual(work.lastWeek, { value: 0.5, of: 2 }); | |
| 151 | − | assert.deepEqual(work.sources, [ | |
| 152 | − | { source: "code", count: 3 }, | |
| 153 | − | { source: "chat", count: 1 }, | |
| 154 | − | { source: "schedule", count: 1 }, | |
| 155 | − | ]); | |
| 156 | − | // Open work goes last on the strip. | |
| 157 | − | assert.deepEqual(work.tasks.slice(-2).map((t) => t.outcome), ["open", "open"]); | |
| 158 | − | assert.equal(work.code, "read"); | |
| 159 | − | assert.equal(work.partial, false); | |
| 160 | − | assert.equal(daySentence(work), "Agents finished 3 tasks. 1 needed a fix after review, and 2 are still open."); | |
| 161 | − | }); | |
| 162 | − | ||
| 163 | − | test("no trend when the week could not all be read, and none without Code", () => { | |
| 164 | − | const partial = dayWork({ now: NOW, timeZone: null, slug: "acme", code: { pulls: [pull()], revisions: {}, complete: false }, sessions: null }); | |
| 165 | − | assert.equal(partial.lastWeek, null); | |
| 166 | − | assert.equal(partial.partial, true); | |
| 167 | − | assert.equal(partial.sessions, "unavailable"); | |
| 168 | − | const member = dayWork({ now: NOW, timeZone: null, slug: "acme", code: "no_access", sessions: { sessions: [session()], complete: true } }); | |
| 169 | − | assert.equal(member.code, "no_access"); | |
| 170 | − | assert.equal(member.rate, null); | |
| 171 | − | assert.equal(member.tasks.every((t) => t.source !== "code"), true); | |
| 172 | − | }); | |
| 173 | − | ||
| 174 | − | test("the trend is in whole points", () => { | |
| 175 | − | assert.equal(trendLabel({ value: 0.89 }, { value: 0.83 }), "+6 pts vs the last 7 days"); | |
| 176 | − | assert.equal(trendLabel({ value: 0.5 }, { value: 0.75 }), "−25 pts vs the last 7 days"); | |
| 177 | − | assert.equal(trendLabel({ value: 0.5 }, { value: 0.5 }), "Same as the last 7 days"); | |
| 178 | − | assert.equal(trendLabel(null, { value: 0.5 }), null); | |
| 179 | − | }); | |
| 180 | − | ||
| 181 | − | test("the sentence for a quiet day, and for what waits", () => { | |
| 182 | − | const quiet = dayWork({ now: NOW, timeZone: null, slug: "acme", code: { pulls: [], revisions: {}, complete: true }, sessions: { sessions: [], complete: true } }); | |
| 183 | − | assert.equal(daySentence(quiet), "No agent work today yet."); | |
| 184 | − | assert.equal(waitingSentence(0), "Nothing is waiting on you."); | |
| 185 | − | assert.equal(waitingSentence(1), "1 thing is waiting on you."); | |
| 186 | − | assert.equal(waitingSentence(8), "8 things are waiting on you."); | |
| 187 | − | }); | |
| 188 | − | ||
| 189 | − | const need = (over: Partial<CodeNeed>): CodeNeed => ({ | |
| 190 | − | key: "pull:p1", | |
| 191 | − | reason: "ready_to_merge", | |
| 192 | − | repo, | |
| 193 | − | ref: "#12", | |
| 194 | − | title: "Add retries", | |
| 195 | − | ask: "Waiting for you to merge it.", | |
| 196 | − | by: { name: "g1t", agent: true }, | |
| 197 | − | for: "bo", | |
| 198 | − | at: NOW - 2 * HOUR, | |
| 199 | − | to: "/acme/web/pull/12", | |
| 200 | − | facts: [{ label: "Files changed", value: "4", tone: null }], | |
| 201 | − | ...over, | |
| 202 | − | }); | |
| 203 | − | ||
| 204 | − | test("Code's needs become rows with what is at stake", () => { | |
| 205 | − | const ready = codeRow(need({})); | |
| 206 | − | assert.equal(ready.kind, "ready"); | |
| 207 | − | assert.equal(ready.stake?.text, "4 files to land"); | |
| 208 | − | assert.equal(ready.action.label, "Merge"); | |
| 209 | − | const deploy = codeRow(need({ key: "deploy:site", reason: "blocking", facts: [{ label: "Production", value: "Serving the build before", tone: "good" }] })); | |
| 210 | − | assert.equal(deploy.kind, "deploy"); | |
| 211 | − | assert.equal(deploy.stake?.text, "Production on an older build"); | |
| 212 | − | assert.equal(codeRow(need({ reason: "checks_failing" })).kind, "pull_blocked"); | |
| 213 | − | assert.equal(codeRow(need({ key: "run:r1", reason: "stalled", facts: [{ label: "Cost so far", value: "$1.20", tone: null }] })).stake?.text, "$1.20 so far"); | |
| 214 | − | }); | |
| 215 | − | ||
| 216 | − | const row = (over: Partial<AttentionRow>): AttentionRow => ({ | |
| 217 | − | key: "k", | |
| 218 | − | kind: "chat", | |
| 219 | − | title: "t", | |
| 220 | − | detail: "d", | |
| 221 | − | stake: null, | |
| 222 | − | action: { label: "Open", to: "/x" }, | |
| 223 | − | owner: null, | |
| 224 | − | at: NOW, | |
| 225 | − | from: "chat", | |
| 226 | − | ...over, | |
| 227 | − | }); | |
| 228 | − | ||
| 229 | − | test("Start here: the most pressing tier first, then what has waited longest", () => { | |
| 230 | − | const rows = [ | |
| 231 | − | row({ key: "chat:old", kind: "chat", at: NOW - 5 * DAY, action: { label: "Reply", to: "/acme/-/chat/general" } }), | |
| 232 | − | row({ key: "ready:new", kind: "ready", at: NOW - HOUR, action: { label: "Merge", to: "/acme/web/pull/2" } }), | |
| 233 | − | row({ key: "ready:old", kind: "ready", at: NOW - 3 * HOUR, action: { label: "Merge", to: "/acme/web/pull/1" } }), | |
| 234 | − | row({ key: "session:s", kind: "session_cap", at: NOW - 10 * 60_000, action: { label: "Approve more", to: "/acme/-/agents/margo/sessions/s" } }), | |
| 235 | − | ]; | |
| 236 | − | const ranked = rankAttention(rows); | |
| 237 | − | assert.deepEqual(ranked.map((r) => r.key), ["session:s", "ready:old", "ready:new", "chat:old"]); | |
| 238 | − | const start = startHere(ranked); | |
| 239 | − | assert.equal(start?.row.key, "session:s"); | |
| 240 | − | assert.match(start?.why ?? "", /spend cap/); | |
| 241 | − | // A failed production deploy beats everything. | |
| 242 | − | const withDeploy = rankAttention([...rows, row({ key: "deploy:site", kind: "deploy", at: NOW, action: { label: "See the build", to: "/acme/site/deployments/1" } })]); | |
| 243 | − | assert.equal(startHere(withDeploy)?.row.key, "deploy:site"); | |
| 244 | − | // Ties in tier say so. | |
| 245 | − | const two = startHere(rankAttention(rows.filter((r) => r.kind === "ready"))); | |
| 246 | − | assert.equal(two?.row.key, "ready:old"); | |
| 247 | − | assert.match(two?.why ?? "", /Of the 2 like it, it has waited longest/); | |
| 248 | − | assert.equal(startHere([]), null); | |
| 249 | − | }); | |
| 250 | − | ||
| 251 | − | test("the same order whatever order the rows came in", () => { | |
| 252 | − | const rows = [row({ key: "b", kind: "review", at: NOW }), row({ key: "a", kind: "review", at: NOW, action: { label: "Review", to: "/y" } })]; | |
| 253 | − | assert.deepEqual(rankAttention(rows).map((r) => r.key), ["a", "b"]); | |
| 254 | − | assert.deepEqual(rankAttention([...rows].reverse()).map((r) => r.key), ["a", "b"]); | |
| 255 | − | }); | |
| 256 | − | ||
| 257 | − | const item = (over: Partial<InboxItem>): InboxItem => ({ | |
| 258 | − | id: "n1", | |
| 259 | − | reason: "ci_activity", | |
| 260 | − | severity: "error", | |
| 261 | − | title: "Checks failed on #12", | |
| 262 | − | body: "Add retries", | |
| 263 | − | event: "check.failed", | |
| 264 | − | repo: "acme/web", | |
| 265 | − | workspace: "acme", | |
| 266 | − | subject: "pull", | |
| 267 | − | number: 12, | |
| 268 | − | url: "/acme/web/pull/12", | |
| 269 | − | actor: "g1t", | |
| 270 | − | count: 1, | |
| 271 | − | createdAt: iso(NOW - HOUR), | |
| 272 | − | updatedAt: iso(NOW - HOUR), | |
| 273 | − | readAt: null, | |
| 274 | − | doneAt: null, | |
| 275 | − | saved: false, | |
| 276 | − | snoozedUntil: null, | |
| 277 | − | ...over, | |
| 278 | − | }); | |
| 279 | − | ||
| 280 | − | test("notifications: warnings and failures in this workspace; none about repositories without Code", () => { | |
| 281 | − | assert.equal(notificationRow(item({}), "acme", true)?.kind, "notification"); | |
| 282 | − | assert.equal(notificationRow(item({}), "acme", false), null); | |
| 283 | − | assert.equal(notificationRow(item({ repo: null, url: "/acme/-/agents/margo" }), "acme", false)?.kind, "notification"); | |
| 284 | − | assert.equal(notificationRow(item({ severity: "info" }), "acme", true), null); | |
| 285 | − | assert.equal(notificationRow(item({ workspace: "other", repo: "other/web" }), "acme", true), null); | |
| 286 | − | assert.equal(notificationRow(item({ readAt: iso(NOW) }), "acme", true), null); | |
| 287 | − | }); | |
| 288 | − | ||
| 289 | − | test("a notification about something already listed is left out", () => { | |
| 290 | − | const out = attention({ | |
| 291 | − | slug: "acme", | |
| 292 | − | code: true, | |
| 293 | − | codeNeeds: [need({})], | |
| 294 | − | capped: [], | |
| 295 | − | agents: [], | |
| 296 | − | canManage: false, | |
| 297 | − | notifications: [item({})], | |
| 298 | − | chat: [], | |
| 299 | − | }); | |
| 300 | − | assert.deepEqual(out.rows.map((r) => r.key), ["pull:p1"]); | |
| 301 | − | assert.deepEqual(out.missing, []); | |
| 302 | − | }); | |
| 303 | − | ||
| 304 | − | test("without Code access no Code row is asked for or shown, and a silent source is named", () => { | |
| 305 | − | const out = attention({ | |
| 306 | − | slug: "acme", | |
| 307 | − | code: false, | |
| 308 | − | codeNeeds: [need({})], | |
| 309 | − | capped: null, | |
| 310 | − | agents: null, | |
| 311 | − | canManage: false, | |
| 312 | − | notifications: [item({}), item({ id: "n2", repo: null, reason: "agent", severity: "warning", url: "/acme/-/agents/margo" })], | |
| 313 | − | chat: null, | |
| 314 | − | }); | |
| 315 | − | assert.deepEqual(out.rows.map((r) => r.key), ["notification:n2"]); | |
| 316 | − | assert.equal(out.rows.some((r) => r.from === "code"), false); | |
| 317 | − | assert.deepEqual(out.missing, ["Agents", "Chat"]); | |
| 318 | − | }); | |
| 319 | − | ||
| 320 | − | test("an agent out of budget is listed only for those who may raise it", () => { | |
| 321 | − | const agent = { id: "a1", handle: "margo", display_name: "Margo", status: "out_of_budget", spent_month_micros: 52_000_000, budget: { monthly_micros: 50_000_000 }, updated_at: iso(NOW) }; | |
| 322 | − | assert.equal(agentRow(agent, "acme")?.stake?.text, "$0.00 left"); | |
| 323 | − | const base = { slug: "acme", code: true, codeNeeds: [], capped: [], notifications: [], chat: [] }; | |
| 324 | − | assert.equal(attention({ ...base, agents: [agent], canManage: false }).rows.length, 0); | |
| 325 | − | assert.equal(attention({ ...base, agents: [agent], canManage: true }).rows[0]?.kind, "agent_budget"); | |
| 326 | − | }); | |
| 327 | − | ||
| 328 | − | test("chat: mentions anywhere and unread direct messages, never muted", () => { | |
| 329 | − | const channel = { id: "c1", kind: "channel" as const, name: "general", last_message_at: iso(NOW) }; | |
| 330 | − | assert.equal(chatRow({ channel, title: "general", muted: false, unread: 4, mentions: 0 }, "acme"), null); | |
| 331 | − | assert.equal(chatRow({ channel, title: "general", muted: false, unread: 4, mentions: 2 }, "acme")?.stake?.text, "2 mentions"); | |
| 332 | − | assert.equal(chatRow({ channel, title: "general", muted: true, unread: 4, mentions: 2 }, "acme"), null); | |
| 333 | − | const dm = chatRow({ channel: { ...channel, kind: "dm", name: null }, title: "Bo Reed", muted: false, unread: 1, mentions: 0 }, "acme"); | |
| 334 | − | assert.equal(dm?.action.to, "/acme/-/chat/dm/c1"); | |
| 335 | − | }); | |
| 336 | − | ||
| 337 | − | test("spent today: today's UTC group of the statement, at price, without money in", () => { | |
| 338 | − | const spend = spendToday( | |
| 339 | − | { | |
| 340 | − | groups: [ | |
| 341 | − | { | |
| 342 | − | key: "2026-10-09", | |
| 343 | − | label: "2026-10-09", | |
| 344 | − | chargedMicros: 0, | |
| 345 | − | lines: [ | |
| 346 | − | { kind: "Agent runs", count: 12, chargedMicros: 3_000_000, costMicros: 2_500_000, priceMicros: 4_000_000 }, | |
| 347 | − | { kind: "Sandbox time", count: 12, chargedMicros: 600_000, costMicros: 500_000 }, | |
| 348 | − | { kind: "Payments", count: 1, chargedMicros: -50_000_000, costMicros: 0, priceMicros: 0 }, | |
| 349 | − | ], | |
| 350 | − | }, | |
| 351 | − | { key: "2026-10-08", label: "2026-10-08", chargedMicros: 0, lines: [{ kind: "Agent runs", count: 3, chargedMicros: 9_000_000, costMicros: 0 }] }, | |
| 352 | − | ], | |
| 353 | − | totals: { chargedMicros: 12_600_000, paidMicros: 50_000_000, costMicros: 0, entries: 4, priceMicros: 13_600_000 }, | |
| 354 | − | }, | |
| 355 | − | NOW, | |
| 356 | − | ); | |
| 357 | − | assert.equal(spend.day, "2026-10-09"); | |
| 358 | − | assert.equal(spend.totalMicros, 4_600_000); | |
| 359 | − | assert.deepEqual(spend.lines.map((l) => l.kind), ["Agent runs", "Sandbox time"]); | |
| 360 | − | assert.equal(spend.monthMicros, 13_600_000); | |
| 361 | − | }); |
| 1 | − | /** | |
| 2 | − | * Today, the workspace's front page: how the day's agent work went, what is | |
| 3 | − | * waiting on you, what the day cost, and the one place to start. Every | |
| 4 | − | * number is worked out here from what the services return, so each | |
| 5 | − | * definition is tested on its own (today.test.ts) and written down in the | |
| 6 | − | * Today guide (apps/docs/.../guides/today.md). Imports only types. | |
| 7 | − | */ | |
| 8 | − | import type { AgentSession, AgentSessionKind, InboxItem, Pull, RepoPath, Statement } from "@g1t/contracts"; | |
| 9 | − | ||
| 10 | − | const MINUTE = 60_000; | |
| 11 | − | const DAY = 24 * 60 * MINUTE; | |
| 12 | − | ||
| 13 | − | /** A day's date in a time zone, as `YYYY-MM-DD`; UTC when the zone is unknown or invalid. */ | |
| 14 | − | export function dayIn(at: number, timeZone: string | null): string { | |
| 15 | − | try { | |
| 16 | − | return new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone: timeZone || "UTC" }).format(at); | |
| 17 | − | } catch { | |
| 18 | − | return new Date(at).toISOString().slice(0, 10); | |
| 19 | − | } | |
| 20 | − | } | |
| 21 | − | ||
| 22 | − | /** The `YYYY-MM-DD` keys of the `count` days before `now`'s day, newest first. */ | |
| 23 | − | export function daysBefore(now: number, timeZone: string | null, count: number): string[] { | |
| 24 | − | const today = dayIn(now, timeZone); | |
| 25 | − | const keys: string[] = []; | |
| 26 | − | // Noon steps never skip or repeat a day across a clock change. | |
| 27 | − | for (let back = 1; keys.length < count && back < count + 3; back++) { | |
| 28 | − | const key = dayIn(now - back * DAY, timeZone); | |
| 29 | − | if (key !== today && !keys.includes(key)) keys.push(key); | |
| 30 | − | } | |
| 31 | − | return keys; | |
| 32 | − | } | |
| 33 | − | ||
| 34 | − | /** "Oct 9", in the reader's time zone. */ | |
| 35 | − | export function shortDate(now: number, timeZone: string | null): string { | |
| 36 | − | const options: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" }; | |
| 37 | − | try { | |
| 38 | − | return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone || "UTC" }).format(now); | |
| 39 | − | } catch { | |
| 40 | − | return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(now); | |
| 41 | − | } | |
| 42 | − | } | |
| 43 | − | ||
| 44 | − | // --- Today's tasks ------------------------------------------------------------ | |
| 45 | − | ||
| 46 | − | /** | |
| 47 | − | * How one of today's tasks went: | |
| 48 | − | * - `accepted`: an agent's pull request merged today without the agent being sent back to revise it. | |
| 49 | − | * - `fixed`: an agent's pull request merged today after one or more revisions. | |
| 50 | − | * - `finished`: an agent session that finished today. Sessions are not reviewed, so they are never `accepted`. | |
| 51 | − | * - `open`: still open, and worked on today. | |
| 52 | − | * - `dropped`: an agent's pull request closed today without merging, or a session that failed or was stopped today. | |
| 53 | − | */ | |
| 54 | − | export type TaskOutcome = "accepted" | "fixed" | "finished" | "open" | "dropped"; | |
| 55 | − | ||
| 56 | − | /** Where a task came from. */ | |
| 57 | − | export type TaskSource = "chat" | "schedule" | "colleague" | "code"; | |
| 58 | − | ||
| 59 | − | export const OUTCOME_LABEL: Record<TaskOutcome, string> = { | |
| 60 | − | accepted: "Accepted first time", | |
| 61 | − | fixed: "Fixed after review", | |
| 62 | − | finished: "Sessions finished", | |
| 63 | − | open: "Still open", | |
| 64 | − | dropped: "Didn't finish", | |
| 65 | − | }; | |
| 66 | − | ||
| 67 | − | export const SOURCE_LABEL: Record<TaskSource, string> = { | |
| 68 | − | chat: "Chat", | |
| 69 | − | schedule: "Schedules", | |
| 70 | − | colleague: "Another agent", | |
| 71 | − | code: "Code", | |
| 72 | − | }; | |
| 73 | − | ||
| 74 | − | /** One mark on the strip. */ | |
| 75 | − | export type DayTask = { | |
| 76 | − | key: string; | |
| 77 | − | outcome: TaskOutcome; | |
| 78 | − | source: TaskSource; | |
| 79 | − | /** Null for a session in a conversation the viewer is not in. */ | |
| 80 | − | title: string | null; | |
| 81 | − | to: string | null; | |
| 82 | − | /** When it settled, or its latest activity while open: the strip's order. */ | |
| 83 | − | at: number; | |
| 84 | − | }; | |
| 85 | − | ||
| 86 | − | /** An agent's pull request with where it is: what Today reads from Code. */ | |
| 87 | − | export type CodePull = Pick<Pull, "number" | "title" | "status" | "mergedAt" | "createdAt" | "updatedAt"> & { | |
| 88 | − | repo: RepoPath; | |
| 89 | − | author: { username: string; kind?: string }; | |
| 90 | − | }; | |
| 91 | − | ||
| 92 | − | /** Whether a pull request is an agent's change: g1t made it, or an agent account opened it. */ | |
| 93 | − | export function isAgentPull(pull: { author: { username: string; kind?: string } }): boolean { | |
| 94 | − | const name = pull.author.username; | |
| 95 | − | return pull.author.kind === "agent" || name === "g1t" || name.endsWith("-agent"); | |
| 96 | − | } | |
| 97 | − | ||
| 98 | − | /** `acme/web#12`, lowercase: how a pull request and its runs are matched. */ | |
| 99 | − | export function pullKey(repo: RepoPath, number: number): string { | |
| 100 | − | return `${repo.namespace}/${repo.name}#${number}`.toLowerCase(); | |
| 101 | − | } | |
| 102 | − | ||
| 103 | − | /** | |
| 104 | − | * How an agent's pull request went on `day`, or null when it is not one of | |
| 105 | − | * that day's tasks. `revisions` counts its `revise` runs: each time g1t | |
| 106 | − | * sent the agent back for failed checks or a review. | |
| 107 | − | */ | |
| 108 | − | export function pullOutcome(pull: CodePull, revisions: number, day: string, timeZone: string | null): TaskOutcome | null { | |
| 109 | − | if (pull.status === "merged") { | |
| 110 | − | if (!pull.mergedAt || dayIn(Date.parse(pull.mergedAt), timeZone) !== day) return null; | |
| 111 | − | return revisions > 0 ? "fixed" : "accepted"; | |
| 112 | − | } | |
| 113 | − | if (pull.status === "closed") return dayIn(Date.parse(pull.updatedAt), timeZone) === day ? "dropped" : null; | |
| 114 | − | // Draft or open: today's when it was worked on today. | |
| 115 | − | return dayIn(Date.parse(pull.updatedAt), timeZone) === day ? "open" : null; | |
| 116 | − | } | |
| 117 | − | ||
| 118 | − | const LIVE: ReadonlySet<string> = new Set(["queued", "working", "waiting", "needs_approval"]); | |
| 119 | − | ||
| 120 | − | /** | |
| 121 | − | * How a session went today, or null when it is not one of today's tasks. | |
| 122 | − | * Only a session at the root of its tree counts: the subagents and | |
| 123 | − | * colleagues it brought in are part of its task. | |
| 124 | − | */ | |
| 125 | − | export function sessionOutcome( | |
| 126 | − | session: Pick<AgentSession, "parent_id" | "status" | "updated_at" | "finished_at">, | |
| 127 | − | day: string, | |
| 128 | − | timeZone: string | null, | |
| 129 | − | ): TaskOutcome | null { | |
| 130 | − | if (session.parent_id) return null; | |
| 131 | − | if (LIVE.has(session.status)) return dayIn(Date.parse(session.updated_at), timeZone) === day ? "open" : null; | |
| 132 | − | const ended = session.finished_at ?? session.updated_at; | |
| 133 | − | if (dayIn(Date.parse(ended), timeZone) !== day) return null; | |
| 134 | − | return session.status === "done" ? "finished" : "dropped"; | |
| 135 | − | } | |
| 136 | − | ||
| 137 | − | export function sessionSource(kind: AgentSessionKind): TaskSource { | |
| 138 | − | return kind === "routine" ? "schedule" : kind === "chat" ? "chat" : "colleague"; | |
| 139 | − | } | |
| 140 | − | ||
| 141 | − | /** The day's agent work, and how the last seven days compare. */ | |
| 142 | − | export type DayWork = { | |
| 143 | − | tasks: DayTask[]; | |
| 144 | − | counts: Record<TaskOutcome, number>; | |
| 145 | − | /** Tasks by where they came from, most first; only sources with any. */ | |
| 146 | − | sources: { source: TaskSource; count: number }[]; | |
| 147 | − | /** | |
| 148 | − | * Accepted first time: of the agents' pull requests that merged or closed | |
| 149 | − | * today, the share that merged without a revision. Null with none. | |
| 150 | − | */ | |
| 151 | − | rate: { value: number; of: number } | null; | |
| 152 | − | /** The same share over the seven days before today; null when there were none, or they could not all be read. */ | |
| 153 | − | lastWeek: { value: number; of: number } | null; | |
| 154 | − | /** Whether Code was read: false for a member without Code access, or when Code did not answer. */ | |
| 155 | − | code: "read" | "no_access" | "unavailable"; | |
| 156 | − | /** Whether sessions were read. */ | |
| 157 | − | sessions: "read" | "unavailable"; | |
| 158 | − | /** True when a list was cut short before the start of the day, so some of today's tasks may be missing. */ | |
| 159 | − | partial: boolean; | |
| 160 | − | }; | |
| 161 | − | ||
| 162 | − | export type CodeWork = { | |
| 163 | − | pulls: CodePull[]; | |
| 164 | − | /** `revise` runs per pull request (`pullKey`). */ | |
| 165 | − | revisions: Record<string, number>; | |
| 166 | − | /** Whether every list read reached back eight days. */ | |
| 167 | − | complete: boolean; | |
| 168 | − | }; | |
| 169 | − | ||
| 170 | − | export type SessionWork = { sessions: AgentSession[]; complete: boolean }; | |
| 171 | − | ||
| 172 | − | /** Accepted first time over a set of settled outcomes: null when nothing settled. */ | |
| 173 | − | export function acceptance(outcomes: TaskOutcome[]): { value: number; of: number } | null { | |
| 174 | − | const settled = outcomes.filter((o) => o === "accepted" || o === "fixed" || o === "dropped"); | |
| 175 | − | if (settled.length === 0) return null; | |
| 176 | − | return { value: settled.filter((o) => o === "accepted").length / settled.length, of: settled.length }; | |
| 177 | − | } | |
| 178 | − | ||
| 179 | − | export function dayWork(input: { | |
| 180 | − | now: number; | |
| 181 | − | timeZone: string | null; | |
| 182 | − | slug: string; | |
| 183 | − | code: CodeWork | null | "no_access"; | |
| 184 | − | sessions: SessionWork | null; | |
| 185 | − | }): DayWork { | |
| 186 | − | const { now, timeZone, slug } = input; | |
| 187 | − | const today = dayIn(now, timeZone); | |
| 188 | − | const tasks: DayTask[] = []; | |
| 189 | − | let lastWeek: DayWork["lastWeek"] = null; | |
| 190 | − | const code = input.code; | |
| 191 | − | if (code && code !== "no_access") { | |
| 192 | − | const week = new Set(daysBefore(now, timeZone, 7)); | |
| 193 | − | const earlier: TaskOutcome[] = []; | |
| 194 | − | for (const pull of code.pulls) { | |
| 195 | − | if (!isAgentPull(pull)) continue; | |
| 196 | − | const revisions = code.revisions[pullKey(pull.repo, pull.number)] ?? 0; | |
| 197 | − | const outcome = pullOutcome(pull, revisions, today, timeZone); | |
| 198 | − | const to = `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`; | |
| 199 | − | if (outcome) { | |
| 200 | − | const at = Date.parse(pull.status === "merged" && pull.mergedAt ? pull.mergedAt : pull.updatedAt); | |
| 201 | − | tasks.push({ key: `pull:${pullKey(pull.repo, pull.number)}`, outcome, source: "code", title: pull.title, to, at }); | |
| 202 | − | continue; | |
| 203 | − | } | |
| 204 | − | // The seven days before: merged or closed on one of them. | |
| 205 | − | if (pull.status === "merged" || pull.status === "closed") { | |
| 206 | − | const settled = pull.status === "merged" ? pull.mergedAt : pull.updatedAt; | |
| 207 | − | if (settled && week.has(dayIn(Date.parse(settled), timeZone))) { | |
| 208 | − | earlier.push(pull.status === "closed" ? "dropped" : revisions > 0 ? "fixed" : "accepted"); | |
| 209 | − | } | |
| 210 | − | } | |
| 211 | − | } | |
| 212 | − | lastWeek = code.complete ? acceptance(earlier) : null; | |
| 213 | − | } | |
| 214 | − | for (const session of input.sessions?.sessions ?? []) { | |
| 215 | − | const outcome = sessionOutcome(session, today, timeZone); | |
| 216 | − | if (!outcome) continue; | |
| 217 | − | tasks.push({ | |
| 218 | − | key: `session:${session.id}`, | |
| 219 | − | outcome, | |
| 220 | − | source: sessionSource(session.kind), | |
| 221 | − | title: session.visible ? session.title : null, | |
| 222 | − | to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}`, | |
| 223 | − | at: Date.parse(outcome === "open" ? session.updated_at : (session.finished_at ?? session.updated_at)), | |
| 224 | − | }); | |
| 225 | − | } | |
| 226 | − | // Settled work first, in the order it settled; open work last. | |
| 227 | − | const order: Record<TaskOutcome, number> = { accepted: 0, fixed: 0, finished: 0, dropped: 0, open: 1 }; | |
| 228 | − | tasks.sort((a, b) => order[a.outcome] - order[b.outcome] || a.at - b.at || a.key.localeCompare(b.key)); | |
| 229 | − | const counts: Record<TaskOutcome, number> = { accepted: 0, fixed: 0, finished: 0, open: 0, dropped: 0 }; | |
| 230 | − | const bySource = new Map<TaskSource, number>(); | |
| 231 | − | for (const task of tasks) { | |
| 232 | − | counts[task.outcome]++; | |
| 233 | − | bySource.set(task.source, (bySource.get(task.source) ?? 0) + 1); | |
| 234 | − | } | |
| 235 | − | const sourceOrder: TaskSource[] = ["chat", "code", "schedule", "colleague"]; | |
| 236 | − | return { | |
| 237 | − | tasks, | |
| 238 | − | counts, | |
| 239 | − | sources: [...bySource] | |
| 240 | − | .map(([source, count]) => ({ source, count })) | |
| 241 | − | .sort((a, b) => b.count - a.count || sourceOrder.indexOf(a.source) - sourceOrder.indexOf(b.source)), | |
| 242 | − | rate: acceptance(tasks.filter((t) => t.source === "code").map((t) => t.outcome)), | |
| 243 | − | lastWeek, | |
| 244 | − | code: code === "no_access" ? "no_access" : code ? "read" : "unavailable", | |
| 245 | − | sessions: input.sessions ? "read" : "unavailable", | |
| 246 | − | partial: (code != null && code !== "no_access" && !code.complete) || (input.sessions != null && !input.sessions.complete), | |
| 247 | − | }; | |
| 248 | − | } | |
| 249 | − | ||
| 250 | − | /** The change in percentage points from the last seven days, rounded: "+6 pts", "−3 pts", "Same". */ | |
| 251 | − | export function trendLabel(rate: { value: number } | null, lastWeek: { value: number } | null): string | null { | |
| 252 | − | if (!rate || !lastWeek) return null; | |
| 253 | − | const points = Math.round(rate.value * 100) - Math.round(lastWeek.value * 100); | |
| 254 | − | if (points === 0) return "Same as the last 7 days"; | |
| 255 | − | return `${points > 0 ? "+" : "−"}${Math.abs(points)} pts vs the last 7 days`; | |
| 256 | − | } | |
| 257 | − | ||
| 258 | − | // --- Needs attention ---------------------------------------------------------- | |
| 259 | − | ||
| 260 | − | /** | |
| 261 | − | * What a row is. Tiers, most pressing first: | |
| 262 | − | * 0. production is down to an older build (`deploy`), or the workspace hit its usage limit (`limit`); | |
| 263 | − | * 1. agent work that has stopped and can't go on without you; | |
| 264 | − | * 2. finished work waiting only for you (merge, review, an invitation, an agent asking); | |
| 265 | − | * 3. something stuck that may need a look; | |
| 266 | − | * 4. an unread notification that is a warning or a failure; | |
| 267 | − | * 5. a mention or a direct message. | |
| 268 | − | */ | |
| 269 | − | export type AttentionKind = | |
| 270 | − | | "limit" | |
| 271 | − | | "deploy" | |
| 272 | − | | "session_cap" | |
| 273 | − | | "agent_budget" | |
| 274 | − | | "pull_blocked" | |
| 275 | − | | "ready" | |
| 276 | − | | "review" | |
| 277 | − | | "invitation" | |
| 278 | − | | "agent_waiting" | |
| 279 | − | | "stuck" | |
| 280 | − | | "runner" | |
| 281 | − | | "notification" | |
| 282 | − | | "chat"; | |
| 283 | − | ||
| 284 | − | export const TIER: Record<AttentionKind, number> = { | |
| 285 | − | limit: 0, | |
| 286 | − | deploy: 0, | |
| 287 | − | session_cap: 1, | |
| 288 | − | agent_budget: 1, | |
| 289 | − | pull_blocked: 1, | |
| 290 | − | ready: 2, | |
| 291 | − | review: 2, | |
| 292 | − | invitation: 2, | |
| 293 | − | agent_waiting: 2, | |
| 294 | − | stuck: 3, | |
| 295 | − | runner: 3, | |
| 296 | − | notification: 4, | |
| 297 | − | chat: 5, | |
| 298 | − | }; | |
| 299 | − | ||
| 300 | − | export type Stake = { text: string; tone: "danger" | "warn" | "accent" | null }; | |
| 301 | − | ||
| 302 | − | /** One row of Needs attention. */ | |
| 303 | − | export type AttentionRow = { | |
| 304 | − | key: string; | |
| 305 | − | kind: AttentionKind; | |
| 306 | − | title: string; | |
| 307 | − | /** One line: what it is and where. */ | |
| 308 | − | detail: string; | |
| 309 | − | /** What is at stake, in a few words. */ | |
| 310 | − | stake: Stake | null; | |
| 311 | − | /** The action, right there. */ | |
| 312 | − | action: { label: string; to: string }; | |
| 313 | − | /** Who is on it: the agent, or the person who asked. */ | |
| 314 | − | owner: { name: string; agent: boolean } | null; | |
| 315 | − | /** Epoch ms: when it started waiting. */ | |
| 316 | − | at: number; | |
| 317 | − | /** Where it came from, for a member without Code access never `code`. */ | |
| 318 | − | from: "code" | "agents" | "notifications" | "chat"; | |
| 319 | − | }; | |
| 320 | − | ||
| 321 | − | /** | |
| 322 | − | * Most pressing first: by tier, then what has waited longest, then by key | |
| 323 | − | * so the order never depends on the order rows were read. One of each key, | |
| 324 | − | * and one of each place: a notification about a pull request already on | |
| 325 | − | * the list is left out. | |
| 326 | − | */ | |
| 327 | − | export function rankAttention(rows: AttentionRow[]): AttentionRow[] { | |
| 328 | − | const sorted = [...rows].sort((a, b) => TIER[a.kind] - TIER[b.kind] || a.at - b.at || a.key.localeCompare(b.key)); | |
| 329 | − | const keys = new Set<string>(); | |
| 330 | − | const places = new Set<string>(); | |
| 331 | − | return sorted.filter((row) => { | |
| 332 | − | const place = row.action.to.split(/[?#]/)[0]; | |
| 333 | − | if (keys.has(row.key) || (row.kind === "notification" && places.has(place))) return false; | |
| 334 | − | keys.add(row.key); | |
| 335 | − | places.add(place); | |
| 336 | − | return true; | |
| 337 | − | }); | |
| 338 | − | } | |
| 339 | − | ||
| 340 | − | /** Start here: the first row by `rankAttention`, with why it was picked. */ | |
| 341 | − | export function startHere(ranked: AttentionRow[]): { row: AttentionRow; why: string } | null { | |
| 342 | − | const row = ranked[0]; | |
| 343 | − | if (!row) return null; | |
| 344 | − | const sameTier = ranked.filter((other) => TIER[other.kind] === TIER[row.kind]).length; | |
| 345 | − | const tail = sameTier > 1 ? ` Of the ${sameTier} like it, it has waited longest.` : ""; | |
| 346 | − | return { row, why: `${WHY[row.kind]}${tail}` }; | |
| 347 | − | } | |
| 348 | − | ||
| 349 | − | const WHY: Record<AttentionKind, string> = { | |
| 350 | − | limit: "Agents start nothing new past the workspace's usage limit, so everything else waits on it.", | |
| 351 | − | deploy: "Production is serving an older build until this is fixed, so it comes before anything else.", | |
| 352 | − | session_cap: "An agent stopped at its spend cap and does nothing more until someone approves more.", | |
| 353 | − | agent_budget: "An agent used up its budget and takes no new work until it is raised.", | |
| 354 | − | pull_blocked: "An agent stopped on this change and won't go on until a person decides.", | |
| 355 | − | ready: "The work is done and only waits for you to land it.", | |
| 356 | − | review: "You were asked by name, so it waits for your verdict.", | |
| 357 | − | invitation: "It is addressed to you; no one else can answer it.", | |
| 358 | − | agent_waiting: "An agent is waiting on you to go on.", | |
| 359 | − | stuck: "A running agent has stopped reporting and may be stuck.", | |
| 360 | − | runner: "A job is waiting for a runner that is not there.", | |
| 361 | − | notification: "It is the most pressing unread notification.", | |
| 362 | − | chat: "Nothing else is waiting, and this is the oldest message for you.", | |
| 363 | − | }; | |
| 364 | − | ||
| 365 | − | // --- Rows from each source ---------------------------------------------------- | |
| 366 | − | ||
| 367 | − | /** Mission control's need row, as Today reads it (lib/mission-control.ts `NeedRow`). */ | |
| 368 | − | export type CodeNeed = { | |
| 369 | − | key: string; | |
| 370 | − | reason: string; | |
| 371 | − | repo: RepoPath | null; | |
| 372 | − | ref: string | null; | |
| 373 | − | title: string; | |
| 374 | − | ask: string; | |
| 375 | − | by: { name: string; agent: boolean } | null; | |
| 376 | − | for: string | null; | |
| 377 | − | at: number; | |
| 378 | − | to: string; | |
| 379 | − | facts: { label: string; value: string; tone: string | null }[]; | |
| 380 | − | }; | |
| 381 | − | ||
| 382 | − | const fact = (need: CodeNeed, label: string) => need.facts.find((f) => f.label === label)?.value ?? null; | |
| 383 | − | ||
| 384 | − | const REASON_STAKE: Record<string, Stake> = { | |
| 385 | − | blocking: { text: "Blocking", tone: "danger" }, | |
| 386 | − | checks_failing: { text: "Checks failing", tone: "danger" }, | |
| 387 | − | outside_guardrails: { text: "Over a guardrail", tone: "warn" }, | |
| 388 | − | low_confidence: { text: "Low confidence", tone: "warn" }, | |
| 389 | − | needs_review: { text: "Needs a verdict", tone: "warn" }, | |
| 390 | − | stalled: { text: "Stalled", tone: "warn" }, | |
| 391 | − | }; | |
| 392 | − | ||
| 393 | − | /** A Code need (failed deploy, pull request, review, invitation, stuck run, runner) as a row. */ | |
| 394 | − | export function codeRow(need: CodeNeed): AttentionRow { | |
| 395 | − | const prefix = need.key.split(":")[0]; | |
| 396 | − | const where = need.repo ? `${need.repo.namespace}/${need.repo.name}${need.ref ? ` ${need.ref}` : ""}` : null; | |
| 397 | − | const detail = where ? `${where} · ${need.ask}` : need.ask; | |
| 398 | − | const owner = need.by; | |
| 399 | − | const base = { key: need.key, title: need.title, detail, owner, at: need.at, from: "code" as const }; | |
| 400 | − | switch (prefix) { | |
| 401 | − | case "deploy": | |
| 402 | − | return { | |
| 403 | − | ...base, | |
| 404 | − | kind: "deploy", | |
| 405 | − | stake: { text: fact(need, "Production") === "Not live yet" ? "Not live yet" : "Production on an older build", tone: "danger" }, | |
| 406 | − | action: { label: "See the build", to: need.to }, | |
| 407 | − | }; | |
| 408 | − | case "invitation": | |
| 409 | − | return { ...base, kind: "invitation", stake: { text: `${fact(need, "Role") ?? "A"} role`, tone: null }, action: { label: "Respond", to: need.to } }; | |
| 410 | − | case "review": { | |
| 411 | − | const lines = fact(need, "Lines"); | |
| 412 | − | return { ...base, kind: "review", stake: { text: lines ?? "Your review", tone: null }, action: { label: "Review", to: need.to } }; | |
| 413 | − | } | |
| 414 | − | case "runner": | |
| 415 | − | return { ...base, kind: "runner", stake: { text: `Waiting ${fact(need, "Waiting") ?? ""}`.trim(), tone: "warn" }, action: { label: "Look", to: need.to } }; | |
| 416 | − | case "run": { | |
| 417 | − | const cost = fact(need, "Cost so far"); | |
| 418 | − | return { | |
| 419 | − | ...base, | |
| 420 | − | kind: "stuck", | |
| 421 | − | stake: { text: cost ? `${cost} so far` : `Quiet ${fact(need, "Quiet for") ?? ""}`.trim(), tone: "warn" }, | |
| 422 | − | action: { label: "Look", to: need.to }, | |
| 423 | − | }; | |
| 424 | − | } | |
| 425 | − | default: { | |
| 426 | − | if (need.reason === "ready_to_merge") { | |
| 427 | − | const files = fact(need, "Files changed"); | |
| 428 | − | return { | |
| 429 | − | ...base, | |
| 430 | − | kind: "ready", | |
| 431 | − | stake: { text: files ? `${files} ${files === "1" ? "file" : "files"} to land` : "Ready to land", tone: "accent" }, | |
| 432 | − | action: { label: "Merge", to: need.to }, | |
| 433 | − | }; | |
| 434 | − | } | |
| 435 | − | return { | |
| 436 | − | ...base, | |
| 437 | − | kind: "pull_blocked", | |
| 438 | − | stake: REASON_STAKE[need.reason] ?? { text: "Waiting on you", tone: "warn" }, | |
| 439 | − | action: { label: need.reason === "low_confidence" || need.reason === "needs_review" ? "Review" : "Decide", to: need.to }, | |
| 440 | − | }; | |
| 441 | − | } | |
| 442 | − | } | |
| 443 | − | } | |
| 444 | − | ||
| 445 | − | /** "$4.20", or "<$0.01" for a sliver. */ | |
| 446 | − | export function dollars(micros: number): string { | |
| 447 | − | const value = micros / 1_000_000; | |
| 448 | − | if (value > 0 && value < 0.01) return "<$0.01"; | |
| 449 | − | return `$${value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; | |
| 450 | − | } | |
| 451 | − | ||
| 452 | − | /** A session stopped at its spend cap that the viewer may approve more for. */ | |
| 453 | − | export function sessionCapRow(session: AgentSession, slug: string): AttentionRow { | |
| 454 | − | const cap = session.cap_micros; | |
| 455 | − | return { | |
| 456 | − | key: `session:${session.id}`, | |
| 457 | − | kind: "session_cap", | |
| 458 | − | title: session.visible ? session.title : `A session of @${session.agent_handle}`, | |
| 459 | − | detail: `@${session.agent_handle} stopped at its spend cap${session.channel_name ? ` in #${session.channel_name}` : ""}.`, | |
| 460 | − | stake: { text: cap != null ? `${dollars(session.charged_micros)} of ${dollars(cap)} cap` : `${dollars(session.charged_micros)} spent`, tone: "warn" }, | |
| 461 | − | action: { label: "Approve more", to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}` }, | |
| 462 | − | owner: { name: session.agent_handle, agent: true }, | |
| 463 | − | at: Date.parse(session.updated_at), | |
| 464 | − | from: "agents", | |
| 465 | − | }; | |
| 466 | − | } | |
| 467 | − | ||
| 468 | − | export type AgentLike = { | |
| 469 | − | id: string; | |
| 470 | − | handle: string; | |
| 471 | − | display_name: string; | |
| 472 | − | status: string; | |
| 473 | − | spent_month_micros: number; | |
| 474 | − | budget: { monthly_micros: number | null }; | |
| 475 | − | updated_at: string; | |
| 476 | − | }; | |
| 477 | − | ||
| 478 | − | /** An agent out of budget (for those who may raise it), or waiting on someone. */ | |
| 479 | − | export function agentRow(agent: AgentLike, slug: string): AttentionRow | null { | |
| 480 | − | const base = { | |
| 481 | − | key: `agent:${agent.id}`, | |
| 482 | − | owner: { name: agent.handle, agent: true }, | |
| 483 | − | at: Date.parse(agent.updated_at), | |
| 484 | − | from: "agents" as const, | |
| 485 | − | }; | |
| 486 | − | if (agent.status === "out_of_budget") { | |
| 487 | − | const budget = agent.budget.monthly_micros; | |
| 488 | − | return { | |
| 489 | − | ...base, | |
| 490 | − | kind: "agent_budget", | |
| 491 | − | title: `${agent.display_name} is out of budget`, | |
| 492 | − | detail: `@${agent.handle} takes no new work this month until its budget is raised.`, | |
| 493 | − | stake: { text: budget != null ? `${dollars(Math.max(0, budget - agent.spent_month_micros))} left` : "Budget used", tone: "danger" }, | |
| 494 | − | action: { label: "Raise the budget", to: `/${slug}/-/agents/${agent.handle}/spend` }, | |
| 495 | − | }; | |
| 496 | − | } | |
| 497 | − | if (agent.status === "waiting") { | |
| 498 | − | return { | |
| 499 | − | ...base, | |
| 500 | − | kind: "agent_waiting", | |
| 501 | − | title: `${agent.display_name} is waiting`, | |
| 502 | − | detail: `@${agent.handle} is waiting before it goes on.`, | |
| 503 | − | stake: null, | |
| 504 | − | action: { label: "Open", to: `/${slug}/-/agents/${agent.handle}` }, | |
| 505 | − | }; | |
| 506 | − | } | |
| 507 | − | return null; | |
| 508 | − | } | |
| 509 | − | ||
| 510 | − | const NOTIFICATION_STAKE: Record<string, string> = { | |
| 511 | − | agent: "Agent waiting", | |
| 512 | − | review_requested: "Review requested", | |
| 513 | − | assign: "Assigned to you", | |
| 514 | − | mention: "Mentioned", | |
| 515 | − | team_mention: "Team mentioned", | |
| 516 | − | ci_activity: "CI activity", | |
| 517 | − | security_alert: "Security alert", | |
| 518 | − | state_change: "State changed", | |
| 519 | − | author: "Your work", | |
| 520 | − | comment: "Comment", | |
| 521 | − | manual: "Subscribed", | |
| 522 | − | subscribed: "Watching", | |
| 523 | − | }; | |
| 524 | − | ||
| 525 | − | /** | |
| 526 | − | * An unread notification that is a warning (someone or something is | |
| 527 | − | * waiting) or a failure, in this workspace. A member without Code access | |
| 528 | − | * gets none about a repository. | |
| 529 | − | */ | |
| 530 | − | export function notificationRow(item: InboxItem, slug: string, code: boolean): AttentionRow | null { | |
| 531 | − | if (item.readAt != null || item.doneAt != null) return null; | |
| 532 | − | if (item.severity !== "warning" && item.severity !== "error") return null; | |
| 533 | − | const inWorkspace = (item.workspace ?? item.repo?.split("/")[0] ?? "").toLowerCase() === slug; | |
| 534 | − | if (!inWorkspace) return null; | |
| 535 | − | if (!code && item.repo) return null; | |
| 536 | − | return { | |
| 537 | − | key: `notification:${item.id}`, | |
| 538 | − | kind: "notification", | |
| 539 | − | title: item.body || item.title, | |
| 540 | − | detail: item.body ? item.title : (item.repo ?? "A notification"), | |
| 541 | − | stake: { text: NOTIFICATION_STAKE[item.reason] ?? "Notification", tone: item.severity === "error" ? "danger" : "warn" }, | |
| 542 | − | action: { label: "Open", to: item.url }, | |
| 543 | − | owner: item.actor ? { name: item.actor, agent: item.actor === "g1t" || item.actor.endsWith("-agent") } : null, | |
| 544 | − | at: Date.parse(item.updatedAt), | |
| 545 | − | from: "notifications", | |
| 546 | − | }; | |
| 547 | − | } | |
| 548 | − | ||
| 549 | − | export type ChatEntryLike = { | |
| 550 | − | channel: { id: string; kind: "channel" | "dm"; name: string | null; last_message_at?: string | null }; | |
| 551 | − | title: string; | |
| 552 | − | muted: boolean; | |
| 553 | − | unread: number; | |
| 554 | − | mentions: number; | |
| 555 | − | }; | |
| 556 | − | ||
| 557 | − | /** A conversation with a mention of you, or a direct message you haven't read. */ | |
| 558 | − | export function chatRow(entry: ChatEntryLike, slug: string): AttentionRow | null { | |
| 559 | − | if (entry.muted) return null; | |
| 560 | − | if (!(entry.mentions > 0 || (entry.channel.kind === "dm" && entry.unread > 0))) return null; | |
| 561 | − | const to = entry.channel.kind === "dm" || !entry.channel.name ? `/${slug}/-/chat/dm/${entry.channel.id}` : `/${slug}/-/chat/${entry.channel.name}`; | |
| 562 | − | const dm = entry.channel.kind === "dm"; | |
| 563 | − | return { | |
| 564 | − | key: `chat:${entry.channel.id}`, | |
| 565 | − | kind: "chat", | |
| 566 | − | title: dm ? entry.title : `#${entry.title}`, | |
| 567 | − | detail: dm ? "A direct message you haven't read." : "You were mentioned.", | |
| 568 | − | stake: { | |
| 569 | − | text: entry.mentions > 0 ? `${entry.mentions} ${entry.mentions === 1 ? "mention" : "mentions"}` : `${entry.unread} unread`, | |
| 570 | − | tone: entry.mentions > 0 ? "accent" : null, | |
| 571 | − | }, | |
| 572 | − | action: { label: "Reply", to }, | |
| 573 | − | owner: null, | |
| 574 | − | at: entry.channel.last_message_at ? Date.parse(entry.channel.last_message_at) : 0, | |
| 575 | − | from: "chat", | |
| 576 | − | }; | |
| 577 | − | } | |
| 578 | − | ||
| 579 | − | /** What Needs attention and Start here show, and which sources did not answer. */ | |
| 580 | − | export type Attention = { | |
| 581 | − | rows: AttentionRow[]; | |
| 582 | − | start: { row: AttentionRow; why: string } | null; | |
| 583 | − | /** Sources that did not answer, by name, for the card to say so. */ | |
| 584 | − | missing: string[]; | |
| 585 | − | }; | |
| 586 | − | ||
| 587 | − | export function attention(input: { | |
| 588 | − | slug: string; | |
| 589 | − | code: boolean; | |
| 590 | − | codeNeeds: CodeNeed[] | null; | |
| 591 | − | capped: AgentSession[] | null; | |
| 592 | − | agents: AgentLike[] | null; | |
| 593 | − | canManage: boolean; | |
| 594 | − | notifications: InboxItem[] | null; | |
| 595 | − | chat: ChatEntryLike[] | null; | |
| 596 | − | }): Attention { | |
| 597 | − | const rows: AttentionRow[] = []; | |
| 598 | − | const missing: string[] = []; | |
| 599 | − | if (input.code) { | |
| 600 | − | if (input.codeNeeds) rows.push(...input.codeNeeds.map(codeRow)); | |
| 601 | − | else missing.push("Code"); | |
| 602 | − | } | |
| 603 | − | if (input.capped) rows.push(...input.capped.map((session) => sessionCapRow(session, input.slug))); | |
| 604 | − | if (input.agents) { | |
| 605 | − | const cappedAgents = new Set((input.capped ?? []).map((s) => s.agent_handle)); | |
| 606 | − | for (const agent of input.agents) { | |
| 607 | − | if (agent.status === "out_of_budget" && !input.canManage) continue; | |
| 608 | − | if (agent.status === "waiting" && cappedAgents.has(agent.handle)) continue; | |
| 609 | − | const row = agentRow(agent, input.slug); | |
| 610 | − | if (row) rows.push(row); | |
| 611 | − | } | |
| 612 | − | } | |
| 613 | − | if (input.capped == null || input.agents == null) missing.push("Agents"); | |
| 614 | − | if (input.notifications) { | |
| 615 | − | for (const item of input.notifications) { | |
| 616 | − | const row = notificationRow(item, input.slug, input.code); | |
| 617 | − | if (row) rows.push(row); | |
| 618 | − | } | |
| 619 | − | } else missing.push("Notifications"); | |
| 620 | − | if (input.chat) { | |
| 621 | − | for (const entry of input.chat) { | |
| 622 | − | const row = chatRow(entry, input.slug); | |
| 623 | − | if (row) rows.push(row); | |
| 624 | − | } | |
| 625 | − | } else missing.push("Chat"); | |
| 626 | − | const ranked = rankAttention(rows); | |
| 627 | − | return { rows: ranked, start: startHere(ranked), missing }; | |
| 628 | − | } | |
| 629 | − | ||
| 630 | − | // --- Spent today -------------------------------------------------------------- | |
| 631 | − | ||
| 632 | − | /** Money in, which the statement lists but which is not spend. */ | |
| 633 | − | const MONEY_IN: ReadonlySet<string> = new Set(["Payments", "AI credit", "Credits from g1t", "Refunds", "Tax", "Card processing fees"]); | |
| 634 | − | ||
| 635 | − | export type Spend = { | |
| 636 | − | /** The statement's day, `YYYY-MM-DD` in UTC. */ | |
| 637 | − | day: string; | |
| 638 | − | /** Usage at price today, in micros. */ | |
| 639 | − | totalMicros: number; | |
| 640 | − | /** A line per kind of charge, as the statement names it, most first. */ | |
| 641 | − | lines: { kind: string; micros: number; count: number }[]; | |
| 642 | − | /** Usage at price this month so far. */ | |
| 643 | − | monthMicros: number; | |
| 644 | − | }; | |
| 645 | − | ||
| 646 | − | /** | |
| 647 | − | * Today's spend from the month's statement grouped by day: each usage | |
| 648 | − | * line at its price (what was charged, plus what the plan, a trial, a pool | |
| 649 | − | * or a discount paid of it), leaving out money in. The statement's days are | |
| 650 | − | * UTC days. | |
| 651 | − | */ | |
| 652 | − | export function spendToday(statement: Pick<Statement, "groups" | "totals">, now: number): Spend { | |
| 653 | − | const day = new Date(now).toISOString().slice(0, 10); | |
| 654 | − | const group = statement.groups.find((g) => g.key === day); | |
| 655 | − | const lines = (group?.lines ?? []) | |
| 656 | − | .filter((line) => !MONEY_IN.has(line.kind)) | |
| 657 | − | .map((line) => ({ kind: line.kind, micros: line.priceMicros ?? line.chargedMicros, count: line.count })) | |
| 658 | − | .filter((line) => line.micros > 0) | |
| 659 | − | .sort((a, b) => b.micros - a.micros || a.kind.localeCompare(b.kind)); | |
| 660 | − | return { | |
| 661 | − | day, | |
| 662 | − | totalMicros: lines.reduce((sum, line) => sum + line.micros, 0), | |
| 663 | − | lines, | |
| 664 | − | monthMicros: statement.totals.priceMicros ?? statement.totals.chargedMicros, | |
| 665 | − | }; | |
| 666 | − | } | |
| 667 | − | ||
| 668 | − | // --- The sentence ----------------------------------------------------------- | |
| 669 | − | ||
| 670 | − | const count = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`; | |
| 671 | − | ||
| 672 | − | /** | |
| 673 | − | * The one sentence under the heading: "Agents finished 35 tasks. 4 needed | |
| 674 | − | * a fix after review, and 6 are still open." Finished counts every task | |
| 675 | − | * that settled well: accepted, fixed, and sessions that finished. | |
| 676 | − | */ | |
| 677 | − | export function daySentence(work: Pick<DayWork, "counts" | "tasks">): string { | |
| 678 | − | if (work.tasks.length === 0) return "No agent work today yet."; | |
| 679 | − | const { accepted, fixed, finished, open, dropped } = work.counts; | |
| 680 | − | const done = accepted + fixed + finished; | |
| 681 | − | const parts: string[] = []; | |
| 682 | − | const first = done > 0 ? `Agents finished ${count(done, "task")}.` : "Agents haven't finished a task yet today."; | |
| 683 | − | const after: string[] = []; | |
| 684 | − | if (fixed > 0) after.push(`${fixed.toLocaleString("en-US")} needed a fix after review`); | |
| 685 | − | if (dropped > 0) after.push(`${dropped.toLocaleString("en-US")} didn't finish`); | |
| 686 | − | if (open > 0) after.push(`${open.toLocaleString("en-US")} ${open === 1 ? "is" : "are"} still open`); | |
| 687 | − | parts.push(first); | |
| 688 | − | if (after.length > 0) { | |
| 689 | − | const last = after.pop(); | |
| 690 | − | const text = after.length > 0 ? `${after.join(", ")}, and ${last}` : String(last); | |
| 691 | − | parts.push(`${text.charAt(0).toUpperCase()}${text.slice(1)}.`); | |
| 692 | − | } | |
| 693 | − | return parts.join(" "); | |
| 694 | − | } | |
| 695 | − | ||
| 696 | − | /** "8 things are waiting on you.", or that nothing is. */ | |
| 697 | − | export function waitingSentence(total: number): string { | |
| 698 | − | if (total === 0) return "Nothing is waiting on you."; | |
| 699 | − | return `${count(total, "thing")} ${total === 1 ? "is" : "are"} waiting on you.`; | |
| 700 | − | } | |
| 701 | − | ||
| 702 | − | /** How long something has waited: "45 min", "17 h", "2 d". */ | |
| 703 | − | export function waited(at: number, now: number): string { | |
| 704 | − | const minutes = Math.max(0, Math.floor((now - at) / MINUTE)); | |
| 705 | − | if (minutes < 60) return `${minutes} min`; | |
| 706 | − | if (minutes < 24 * 60) return `${Math.floor(minutes / 60)} h`; | |
| 707 | − | return `${Math.floor(minutes / (24 * 60))} d`; | |
| 708 | − | } |
| 24 | 24 | test("old addresses go to where their pages are now", () => { | |
| 25 | 25 | // Members and invites is a page of People's again. | |
| 26 | 26 | assert.equal(workspaceRedirect("/acme/-/members"), null); | |
| 27 | − | assert.equal(workspaceRedirect("/acme/-/home", "?x=1"), "/acme/-/today?x=1"); | |
| 27 | + | assert.equal(workspaceRedirect("/acme/-/today", "?x=1"), "/acme/-/home?x=1"); | |
| 28 | + | // Home is the front page itself: not an old address. | |
| 29 | + | assert.equal(workspaceRedirect("/acme/-/home"), null); | |
| 28 | 30 | // Code's Overview lives at -/overview now: not an old address. | |
| 29 | 31 | assert.equal(workspaceRedirect("/acme/-/overview"), null); | |
| 30 | 32 | assert.equal(workspaceRedirect("/acme/-/soon/teams"), "/acme/-/teams"); | |
| ⋯ | |||
| 53 | 55 | test("a click's data request is for the same page as a full load", () => { | |
| 54 | 56 | assert.equal(pagePath("/acme/-/insights.data"), "/acme/-/insights"); | |
| 55 | 57 | assert.equal(pagePath("/acme/-/insights/"), "/acme/-/insights"); | |
| 56 | − | assert.equal(workspaceRedirect("/acme/-/home.data", "?_routes=routes%2Fworkspace%2Fmoved-home"), "/acme/-/today"); | |
| 57 | − | assert.equal(workspaceRedirect("/acme/-/home.data", "?_routes=x&q=ada"), "/acme/-/today?q=ada"); | |
| 58 | + | assert.equal(workspaceRedirect("/acme/-/today.data", "?_routes=routes%2Fworkspace%2Fmoved-today"), "/acme/-/home"); | |
| 59 | + | assert.equal(workspaceRedirect("/acme/-/today.data", "?_routes=x&q=ada"), "/acme/-/home?q=ada"); | |
| 58 | 60 | }); | |
| 59 | 61 | ||
| 60 | 62 | test("an alias or old name leads to the same page under the workspace", () => { | |
| ⋯ | |||
| 74 | 76 | ||
| 75 | 77 | test("the dock's mode follows the address", async () => { | |
| 76 | 78 | const { modeOf, modeHome, homePath } = await import("./workspace-nav.ts"); | |
| 77 | − | assert.equal(modeOf("/", "acme"), "today"); | |
| 78 | − | assert.equal(modeOf("/acme", "acme"), "today"); | |
| 79 | − | assert.equal(modeOf("/acme/-/today", "acme"), "today"); | |
| 79 | + | assert.equal(modeOf("/", "acme"), "home"); | |
| 80 | + | assert.equal(modeOf("/acme", "acme"), "home"); | |
| 81 | + | assert.equal(modeOf("/acme/-/home", "acme"), "home"); | |
| 80 | 82 | // g1t's own public pages light no mode. | |
| 81 | 83 | assert.equal(modeOf("/explore", "acme"), "site"); | |
| 82 | 84 | assert.equal(modeOf("/u/ada", "acme"), "site"); | |
| ⋯ | |||
| 111 | 113 | assert.equal(modeOf("/new", "acme"), "code"); | |
| 112 | 114 | // Another workspace's chat is not this one's mode; its repositories are Code's. | |
| 113 | 115 | assert.equal(modeOf("/other/-/chat", "acme"), "code"); | |
| 114 | − | assert.equal(modeHome("today", "acme"), "/acme/-/today"); | |
| 116 | + | assert.equal(modeHome("home", "acme"), "/acme/-/home"); | |
| 115 | 117 | assert.equal(modeHome("notifications", "acme"), "/notifications"); | |
| 116 | 118 | assert.equal(modeHome("people", "acme"), "/acme/-/people"); | |
| 117 | 119 | assert.equal(modeHome("apps", "acme"), "/acme/-/apps"); | |
| 118 | 120 | assert.equal(modeHome("code", "acme"), "/acme/-/overview"); | |
| 119 | 121 | assert.equal(modeHome("workspace", "acme"), "/acme/-/workspace"); | |
| 120 | − | assert.equal(homePath("acme"), "/acme/-/today"); | |
| 122 | + | assert.equal(homePath("acme"), "/acme/-/home"); | |
| 121 | 123 | assert.equal(homePath("acme", "?agent=new"), "/acme/-/overview?agent=new"); | |
| 122 | 124 | assert.equal(homePath("acme", "?tab=landed&_routes=x"), "/acme/-/overview?tab=landed"); | |
| 123 | 125 | }); | |
| ⋯ | |||
| 125 | 127 | test("a member without Code access is sent around Code's pages", async () => { | |
| 126 | 128 | const { codeGate } = await import("./workspace-nav.ts"); | |
| 127 | 129 | const none = ["acme"]; | |
| 128 | − | assert.equal(codeGate("/", "", none, "acme"), "/acme/-/today"); | |
| 130 | + | assert.equal(codeGate("/", "", none, "acme"), "/acme/-/home"); | |
| 129 | 131 | assert.equal(codeGate("/", "", none, "other"), null); | |
| 130 | − | assert.equal(codeGate("/acme", "", none, "acme"), "/acme/-/today"); | |
| 132 | + | assert.equal(codeGate("/acme", "", none, "acme"), "/acme/-/home"); | |
| 131 | 133 | assert.equal(codeGate("/acme/web/pull/3", "?x=1", none, "acme"), "/acme/-/code-access?from=%2Facme%2Fweb%2Fpull%2F3%3Fx%3D1"); | |
| 132 | 134 | assert.equal(codeGate("/acme/-/projects", "", none, "acme"), "/acme/-/code-access?from=%2Facme%2F-%2Fprojects"); | |
| 133 | 135 | assert.equal(codeGate("/acme/-/chat/general", "", none, "acme"), null); | |
| 60 | 60 | ||
| 61 | 61 | /** Workspace pages that moved, by their old name under `-/`. */ | |
| 62 | 62 | const MOVED: Record<string, string> = { | |
| 63 | − | home: "-/today", | |
| 63 | + | // Home was called Today for a while. | |
| 64 | + | today: "-/home", | |
| 64 | 65 | "soon/teams": "-/teams", | |
| 65 | 66 | "soon/insights": "-/insights", | |
| 66 | 67 | }; | |
| ⋯ | |||
| 122 | 123 | } | |
| 123 | 124 | ||
| 124 | 125 | /** | |
| 125 | − | * The dock's modes. Today is the front page; | |
| 126 | + | * The dock's modes. Home is the front page; | |
| 126 | 127 | * Chat, Agents, Code and Artifacts are where work happens; Notifications | |
| 127 | 128 | * spans them; People is who belongs; Workspace is the workspace itself: | |
| 128 | 129 | * its money, policies and settings; Apps is everything installed that you | |
| ⋯ | |||
| 132 | 133 | * sidebar of its own, or none, and which one is lit follows the address. | |
| 133 | 134 | */ | |
| 134 | 135 | export type ModeKey = | |
| 135 | − | | "today" | |
| 136 | + | | "home" | |
| 136 | 137 | | "chat" | |
| 137 | 138 | | "notifications" | |
| 138 | 139 | | "agents" | |
| ⋯ | |||
| 146 | 147 | ||
| 147 | 148 | /** Workspace pages under `-/`, by the mode they belong to. Anything else of the workspace's is Code's. */ | |
| 148 | 149 | const PAGE_MODES: Record<string, ModeKey> = { | |
| 149 | − | today: "today", | |
| 150 | + | home: "home", | |
| 150 | 151 | chat: "chat", | |
| 151 | 152 | artifacts: "artifacts", | |
| 152 | 153 | agents: "agents", | |
| ⋯ | |||
| 188 | 189 | */ | |
| 189 | 190 | export function modeOf(pathname: string, slug: string | null): ModeKey { | |
| 190 | 191 | const path = pagePath(pathname); | |
| 191 | − | if (path === "/") return "today"; | |
| 192 | + | if (path === "/") return "home"; | |
| 192 | 193 | if (path === "/notifications" || path.startsWith("/notifications/")) return "notifications"; | |
| 193 | 194 | if (path === "/settings" || path.startsWith("/settings/")) return "account"; | |
| 194 | 195 | const parts = path.split("/").filter(Boolean); | |
| 195 | 196 | if (SITE_PAGES.has(parts[0] ?? "")) return "site"; | |
| 196 | 197 | if (slug && parts[0]?.toLowerCase() === slug.toLowerCase()) { | |
| 197 | − | if (parts.length === 1) return "today"; | |
| 198 | + | if (parts.length === 1) return "home"; | |
| 198 | 199 | if (parts[1] === "-") { | |
| 199 | 200 | // The workspace's security settings are a policy; its alerts are Code's. | |
| 200 | 201 | if (parts[2] === "security" && parts[3] === "settings") return "workspace"; | |
| ⋯ | |||
| 207 | 208 | /** Where each mode's button in the dock goes, in the workspace `slug`. */ | |
| 208 | 209 | export function modeHome(mode: ModeKey, slug: string): string { | |
| 209 | 210 | switch (mode) { | |
| 210 | − | case "today": | |
| 211 | − | return todayPath(slug); | |
| 211 | + | case "home": | |
| 212 | + | return homePagePath(slug); | |
| 212 | 213 | case "notifications": | |
| 213 | 214 | return "/notifications"; | |
| 214 | 215 | case "chat": | |
| ⋯ | |||
| 232 | 233 | } | |
| 233 | 234 | } | |
| 234 | 235 | ||
| 235 | − | /** A workspace's front page: Today. */ | |
| 236 | − | export function todayPath(slug: string): string { | |
| 237 | − | return `/${slug}/-/today`; | |
| 236 | + | /** A workspace's front page: Home. */ | |
| 237 | + | export function homePagePath(slug: string): string { | |
| 238 | + | return `/${slug}/-/home`; | |
| 238 | 239 | } | |
| 239 | 240 | ||
| 240 | 241 | /** | |
| 241 | 242 | * Where g1t's front door (`/`) and a workspace's own address lead someone | |
| 242 | − | * in the workspace `slug`: Today, or Code's Overview when the query asks | |
| 243 | + | * in the workspace `slug`: Home, or Code's Overview when the query asks | |
| 243 | 244 | * for its panels (a tab, or `?agent=new` to put an agent on something). | |
| 244 | 245 | */ | |
| 245 | 246 | export function homePath(slug: string, search = ""): string { | |
| ⋯ | |||
| 248 | 249 | params.delete("index"); | |
| 249 | 250 | const query = params.toString(); | |
| 250 | 251 | if (params.has("agent") || params.has("tab") || params.has("sort")) return `/${slug}/-/overview?${query}`; | |
| 251 | − | return todayPath(slug); | |
| 252 | + | return homePagePath(slug); | |
| 252 | 253 | } | |
| 253 | 254 | ||
| 254 | 255 | /** The page a member without Code access sees in place of anything of Code's. */ | |
| ⋯ | |||
| 263 | 264 | * Where a member without Code access goes instead of `pathname`, or null | |
| 264 | 265 | * when the page is open to them. `noCode` is the workspaces (by slug) where | |
| 265 | 266 | * they lack it; `chosen` is the one they are in. g1t's front door (`/`) is | |
| 266 | − | * Today in that workspace, and so is the workspace's own address; its | |
| 267 | + | * Home in that workspace, and so is the workspace's own address; its | |
| 267 | 268 | * Code pages and every repository page is the page that says to ask an | |
| 268 | 269 | * owner. | |
| 269 | 270 | */ | |
| ⋯ | |||
| 272 | 273 | const path = pagePath(pathname); | |
| 273 | 274 | if (path === "/") { | |
| 274 | 275 | const slug = chosen && noCode.includes(chosen.toLowerCase()) ? chosen.toLowerCase() : null; | |
| 275 | − | return slug ? todayPath(slug) : null; | |
| 276 | + | return slug ? homePagePath(slug) : null; | |
| 276 | 277 | } | |
| 277 | 278 | const parts = path.split("/").filter(Boolean); | |
| 278 | 279 | const slug = parts[0]?.toLowerCase(); | |
| 279 | 280 | if (!slug || !noCode.includes(slug)) return null; | |
| 280 | 281 | const from = `${path}${search && search !== "?" ? search : ""}`; | |
| 281 | − | // The workspace's own page is Today. | |
| 282 | − | if (parts.length === 1) return todayPath(slug); | |
| 282 | + | // The workspace's own page is Home. | |
| 283 | + | if (parts.length === 1) return homePagePath(slug); | |
| 283 | 284 | if (parts[1] === "-") return CODE_PAGES.has(parts[2] ?? "") ? codeAccessPath(slug, from) : null; | |
| 284 | 285 | // A repository, and everything in it. | |
| 285 | 286 | return codeAccessPath(slug, from); | |
| 211 | 211 | route(":folio", "routes/workspace/folios/folio.tsx"), | |
| 212 | 212 | route(":folio/history", "routes/workspace/folios/history.tsx"), | |
| 213 | 213 | ]), | |
| 214 | − | // Today, the workspace's front page (`-/home` leads there), and every | |
| 215 | − | // app in it you can use, with pinning them to your dock. | |
| 216 | − | route("-/today", "routes/workspace/home.tsx"), | |
| 217 | − | route("-/home", "routes/workspace/moved.ts", { id: "routes/workspace/moved-home" }), | |
| 214 | + | // Home, the workspace's front page (`-/today` leads there), where it | |
| 215 | + | // marks your visit, and every app in it you can use, with pinning them | |
| 216 | + | // to your dock. | |
| 217 | + | route("-/home", "routes/workspace/home.tsx"), | |
| 218 | + | route("-/home/seen", "routes/workspace/home-seen.ts"), | |
| 219 | + | route("-/today", "routes/workspace/moved.ts", { id: "routes/workspace/moved-today" }), | |
| 218 | 220 | route("-/apps", "routes/workspace/apps.tsx"), | |
| 219 | 221 | // The Marketplace: anyone browses it; owners add extensions and | |
| 220 | 222 | // integrations, and everyone else asks (`requests` takes every form it |
| 65 | 65 | Back to Chat | |
| 66 | 66 | </Link> | |
| 67 | 67 | <Link | |
| 68 | − | to={`/${params.owner}/-/today`} | |
| 68 | + | to={`/${params.owner}/-/home`} | |
| 69 | 69 | className="inline-flex h-9 items-center rounded-md border border-line px-3.5 text-sm font-medium text-fg/90 transition-colors hover:border-line-strong hover:bg-surface" | |
| 70 | 70 | > | |
| 71 | − | Today | |
| 71 | + | Home | |
| 72 | 72 | </Link> | |
| 73 | 73 | </div> | |
| 74 | 74 | <p className="mt-6 text-xs text-faint">Agents can still explain how things work and what changed; they never show source code.</p> |
| 1 | + | /** | |
| 2 | + | * `POST -/home/seen` with `{ "at": <epoch ms> }`: Home marks the viewer's | |
| 3 | + | * visit once the page has been in view for a few seconds, with the time it | |
| 4 | + | * loaded (routes/workspace/home.tsx). Notify keeps it per person and | |
| 5 | + | * workspace and only moves it forward. | |
| 6 | + | */ | |
| 7 | + | import { data } from "react-router"; | |
| 8 | + | ||
| 9 | + | import type { Route } from "./+types/home-seen"; | |
| 10 | + | import { markVisit } from "../../lib/home.server"; | |
| 11 | + | import { assertSameOrigin, requireUser, roleIn } from "../../lib/session.server"; | |
| 12 | + | ||
| 13 | + | export async function action({ params, context, request }: Route.ActionArgs) { | |
| 14 | + | if (request.method !== "POST") throw data(null, { status: 405 }); | |
| 15 | + | assertSameOrigin(request); | |
| 16 | + | const viewer = requireUser(context, request); | |
| 17 | + | const slug = params.owner.toLowerCase(); | |
| 18 | + | if (!roleIn(viewer, slug)) throw data(null, { status: 404 }); | |
| 19 | + | const sent = (await request.json().catch(() => ({}))) as { at?: unknown }; | |
| 20 | + | const at = typeof sent.at === "number" && Number.isFinite(sent.at) ? Math.min(sent.at, Date.now()) : Date.now(); | |
| 21 | + | const ok = await markVisit(viewer, slug, at); | |
| 22 | + | return new Response(null, { status: ok ? 204 : 503, headers: { "cache-control": "no-store" } }); | |
| 23 | + | } |
| 1 | 1 | import { ArrowRight } from "lucide-react"; | |
| 2 | − | import { Suspense, useState } from "react"; | |
| 2 | + | import { Suspense, useEffect, useState } from "react"; | |
| 3 | 3 | import { Await, Form, Link, data, redirect, useNavigation } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { hasCodeAccess } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/home"; | |
| 8 | − | import { AttentionList, Card, DayCard, DaySkeleton, RowsSkeleton, Sources, SpendCard, SpendSkeleton, StartHere } from "../../components/today"; | |
| 8 | + | import { | |
| 9 | + | Card, | |
| 10 | + | DecisionsCard, | |
| 11 | + | DeploysCard, | |
| 12 | + | LandedCard, | |
| 13 | + | NeedsList, | |
| 14 | + | RowsSkeleton, | |
| 15 | + | RunningList, | |
| 16 | + | Sources, | |
| 17 | + | SpanCard, | |
| 18 | + | SpanSkeleton, | |
| 19 | + | SpendCard, | |
| 20 | + | SpendSkeleton, | |
| 21 | + | StartHere, | |
| 22 | + | WindowSwitch, | |
| 23 | + | } from "../../components/home"; | |
| 9 | 24 | import { G1tMark, isOrchestrator } from "../../components/orchestrator"; | |
| 10 | 25 | import { Skeleton } from "../../components/ui/skeleton"; | |
| 11 | 26 | import { channelPath } from "../../lib/chat"; | |
| 12 | 27 | import { sidebarOrNull } from "../../lib/chat.server"; | |
| 28 | + | import { | |
| 29 | + | type Attention, | |
| 30 | + | type SpanWork, | |
| 31 | + | attention, | |
| 32 | + | decisionsIn, | |
| 33 | + | deploysIn, | |
| 34 | + | landedIn, | |
| 35 | + | parseWindow, | |
| 36 | + | running, | |
| 37 | + | runningChanges, | |
| 38 | + | runningDeploys, | |
| 39 | + | runningSessions, | |
| 40 | + | spanFor, | |
| 41 | + | spanSentence, | |
| 42 | + | spanWork, | |
| 43 | + | waitingSentence, | |
| 44 | + | } from "../../lib/home"; | |
| 45 | + | import { | |
| 46 | + | loadAgentsOverview, | |
| 47 | + | loadCodeWork, | |
| 48 | + | loadDeploys, | |
| 49 | + | loadInstallRequests, | |
| 50 | + | loadLastVisit, | |
| 51 | + | loadMemories, | |
| 52 | + | loadSessions, | |
| 53 | + | loadSpend, | |
| 54 | + | reachFor, | |
| 55 | + | } from "../../lib/home.server"; | |
| 13 | 56 | import { readCookie } from "../../lib/mission"; | |
| 14 | 57 | import { loadMissionControl } from "../../lib/mission-control.server"; | |
| 15 | 58 | import { page } from "../../lib/meta"; | |
| 16 | 59 | import { chat, inbox, workspaceAgents } from "../../lib/services.server"; | |
| 17 | 60 | import { assertSameOrigin, requireUser, roleIn } from "../../lib/session.server"; | |
| 18 | − | import { type Attention, type DayWork, attention, daySentence, dayWork, shortDate, waitingSentence } from "../../lib/today"; | |
| 19 | − | import { loadCapped, loadCodeWork, loadSessions, loadSpend } from "../../lib/today.server"; | |
| 61 | + | import { homePagePath } from "../../lib/workspace-nav"; | |
| 20 | 62 | ||
| 21 | 63 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 22 | − | return page(args, { title: `Today · ${params.owner} · g1t` }); | |
| 64 | + | return page(args, { title: `Home · ${params.owner} · g1t` }); | |
| 23 | 65 | } | |
| 24 | 66 | ||
| 25 | − | /** Unread notifications read for Needs attention. */ | |
| 67 | + | /** Unread notifications read for Needs you. */ | |
| 26 | 68 | const NOTIFICATIONS_READ = 50; | |
| 27 | 69 | ||
| 28 | 70 | /** | |
| 29 | − | * Today: the workspace's front page. A sentence about the day, how the | |
| 30 | − | * agents' work went, what needs you with the action right there, what the | |
| 31 | − | * day cost, and one place to start. Only the agents list (for Ask g1t) is | |
| 32 | − | * waited for; every section streams in on its own, and one whose service | |
| 33 | − | * does not answer says so. Definitions: lib/today.ts and the Today guide. | |
| 71 | + | * Home: the workspace's front page. Where you're needed now, with no time | |
| 72 | + | * window; what happened since you were last here (or over the last 24 | |
| 73 | + | * hours or 7 days, `?window=`); what is running; what the span cost; and | |
| 74 | + | * one place to start. The last visit is kept by notify, per person and | |
| 75 | + | * workspace, and marked by the page once it has been looked at | |
| 76 | + | * (routes/workspace/home-seen.ts), so a refresh never wipes it. Only the | |
| 77 | + | * agents list (for Ask g1t) and the last visit (for the span) are waited | |
| 78 | + | * for; every section streams in on its own, and one whose service does | |
| 79 | + | * not answer says so. Definitions: lib/home.ts and the Home guide. | |
| 34 | 80 | */ | |
| 35 | 81 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 36 | 82 | const viewer = requireUser(context, request); | |
| ⋯ | |||
| 40 | 86 | const code = hasCodeAccess(membership); | |
| 41 | 87 | const now = Date.now(); | |
| 42 | 88 | const tz = readCookie(request.headers.get("cookie"), "g1t_tz"); | |
| 89 | + | const windowKey = parseWindow(new URL(request.url).searchParams.get("window")); | |
| 43 | 90 | ||
| 91 | + | // What needs you has no window: read at once, beside the last visit. | |
| 44 | 92 | const agentsP = workspaceAgents | |
| 45 | 93 | .list(slug, viewer) | |
| 46 | 94 | .then((result) => (result.ok ? result.value.filter((agent) => !agent.archived_at) : null)) | |
| 47 | 95 | .catch(() => null); | |
| 48 | − | const codeWorkP = code ? loadCodeWork(viewer, slug, now).catch(() => null) : Promise.resolve("no_access" as const); | |
| 49 | − | const sessionsP = loadSessions(viewer, slug, now, tz).catch(() => null); | |
| 50 | − | const codeNeedsP = code | |
| 51 | − | ? loadMissionControl(viewer, request, slug) | |
| 52 | − | .then(({ value }) => value.needs) | |
| 53 | − | .catch(() => null) | |
| 54 | − | : Promise.resolve(null); | |
| 55 | − | const cappedP = loadCapped(viewer, slug).catch(() => null); | |
| 96 | + | const overviewP = loadAgentsOverview(viewer, slug).catch(() => null); | |
| 97 | + | const missionP = code ? loadMissionControl(viewer, request, slug).then(({ value }) => value).catch(() => null) : Promise.resolve(null); | |
| 98 | + | const requestsP = loadInstallRequests(viewer, slug).catch(() => null); | |
| 56 | 99 | const chatP = sidebarOrNull(slug, viewer); | |
| 57 | 100 | const notificationsP = inbox | |
| 58 | 101 | .list(viewer, { unread: true, limit: NOTIFICATIONS_READ }) | |
| 59 | 102 | .then((result) => result.items) | |
| 60 | 103 | .catch(() => null); | |
| 104 | + | const lastSeenP = windowKey === "last" ? loadLastVisit(viewer, slug).catch(() => undefined) : Promise.resolve(null); | |
| 61 | 105 | ||
| 62 | − | const day: Promise<DayWork | null> = Promise.all([codeWorkP, sessionsP]) | |
| 63 | − | .then(([codeWork, sessions]) => | |
| 64 | − | codeWork == null && sessions == null ? null : dayWork({ now, timeZone: tz, slug, code: codeWork, sessions }), | |
| 65 | − | ) | |
| 66 | − | .catch(() => null); | |
| 67 | − | const waiting: Promise<Attention> = Promise.all([codeNeedsP, cappedP, agentsP, notificationsP, chatP]).then( | |
| 68 | − | ([codeNeeds, capped, agents, notifications, sidebar]) => | |
| 69 | − | attention({ | |
| 106 | + | const waiting: Promise<Attention> = Promise.all([missionP, overviewP, agentsP, requestsP, notificationsP, chatP]) | |
| 107 | + | .then(([mission, overview, agents, requests, notifications, sidebar]) => { | |
| 108 | + | const capped = overview?.waiting_on_you ?? null; | |
| 109 | + | const latestCap = Math.max(0, ...(capped ?? []).map((session) => Date.parse(session.updated_at))); | |
| 110 | + | const monthStart = Date.parse(`${new Date(now).toISOString().slice(0, 7)}-01T00:00:00Z`); | |
| 111 | + | return attention({ | |
| 70 | 112 | slug, | |
| 71 | 113 | code, | |
| 72 | − | codeNeeds, | |
| 73 | − | capped: capped?.capped ?? null, | |
| 114 | + | codeNeeds: mission?.needs ?? null, | |
| 115 | + | capped, | |
| 74 | 116 | agents, | |
| 75 | − | canManage: capped?.canManage ?? false, | |
| 117 | + | canManage: overview?.can_manage ?? false, | |
| 118 | + | limit: overview?.can_manage ? { alert: overview.alert, spentMicros: overview.spent_month_micros, since: latestCap || monthStart } : null, | |
| 119 | + | requests: requests?.can_resolve ? requests.requests : null, | |
| 76 | 120 | notifications, | |
| 77 | 121 | chat: sidebar?.entries ?? null, | |
| 78 | − | }), | |
| 79 | − | ).catch(() => ({ rows: [], start: null, missing: ["Code", "Agents", "Notifications", "Chat"] })); | |
| 80 | − | const spend = loadSpend(viewer, slug, now).catch(() => null); | |
| 122 | + | }); | |
| 123 | + | }) | |
| 124 | + | .catch(() => ({ rows: [], start: null, missing: ["Code", "Agents", "Notifications", "Chat"] })); | |
| 125 | + | ||
| 126 | + | const span = spanFor(windowKey, now, await lastSeenP, tz); | |
| 127 | + | ||
| 128 | + | // What happened in the span. | |
| 129 | + | const codeWorkP = code ? loadCodeWork(viewer, slug, reachFor(span)).catch(() => null) : Promise.resolve("no_access" as const); | |
| 130 | + | const sessionsP = loadSessions(viewer, slug, span.from).catch(() => null); | |
| 131 | + | const deploysP = code ? loadDeploys(viewer, slug, span.from).catch(() => null) : Promise.resolve(null); | |
| 132 | + | const memoriesP = code ? loadMemories(viewer, slug).catch(() => null) : Promise.resolve(null); | |
| 133 | + | ||
| 134 | + | const work: Promise<SpanWork | null> = Promise.all([codeWorkP, sessionsP]) | |
| 135 | + | .then(([codeWork, sessions]) => (codeWork == null && sessions == null ? null : spanWork({ span, slug, code: codeWork, sessions }))) | |
| 136 | + | .catch(() => null); | |
| 137 | + | const happened = Promise.all([codeWorkP, deploysP, memoriesP, requestsP]) | |
| 138 | + | .then(([codeWork, deploys, memories, requests]) => ({ | |
| 139 | + | landed: codeWork && codeWork !== "no_access" ? landedIn(codeWork.pulls, span) : null, | |
| 140 | + | deploys: deploys ? deploysIn(deploys.projects, slug, span) : null, | |
| 141 | + | deploysComplete: deploys?.complete ?? true, | |
| 142 | + | decisions: decisionsIn({ memories, requests: requests?.requests ?? null }, slug, span), | |
| 143 | + | decisionsRead: { memories: !code || memories != null, requests: requests != null }, | |
| 144 | + | })) | |
| 145 | + | .catch(() => null); | |
| 146 | + | const runningNow = Promise.all([sessionsP, missionP, deploysP]) | |
| 147 | + | .then(([sessions, mission, deploys]) => ({ | |
| 148 | + | rows: running([ | |
| 149 | + | runningSessions(sessions?.sessions ?? [], slug), | |
| 150 | + | runningChanges(mission?.waiting ?? []), | |
| 151 | + | runningDeploys(deploys?.overview ?? [], slug), | |
| 152 | + | ]), | |
| 153 | + | missing: [...(sessions ? [] : ["Agents"]), ...(code && !mission ? ["Code"] : []), ...(code && !deploys ? ["Deployments"] : [])], | |
| 154 | + | })) | |
| 155 | + | .catch(() => ({ rows: [], missing: ["Agents"] })); | |
| 156 | + | const spend = loadSpend(viewer, slug, span).catch(() => null); | |
| 81 | 157 | ||
| 82 | 158 | const agents = await agentsP; | |
| 83 | 159 | return { | |
| 84 | 160 | slug, | |
| 85 | 161 | code, | |
| 86 | 162 | now, | |
| 87 | − | date: shortDate(now, tz), | |
| 163 | + | tz, | |
| 164 | + | span, | |
| 88 | 165 | orchestrator: agents?.some(isOrchestrator) ?? false, | |
| 89 | − | day, | |
| 166 | + | work, | |
| 167 | + | happened, | |
| 90 | 168 | attention: waiting, | |
| 91 | − | /** The sentence under the heading needs both. */ | |
| 92 | − | head: Promise.all([day, waiting]), | |
| 169 | + | running: runningNow, | |
| 170 | + | /** The sentence under the heading needs the work, what landed and what waits. */ | |
| 171 | + | head: Promise.all([work, happened, waiting]), | |
| 93 | 172 | spend, | |
| 94 | 173 | }; | |
| 95 | 174 | } | |
| ⋯ | |||
| 119 | 198 | /** Ask g1t, as one bar: Enter sends; Shift+Enter is a new line. Chat is one click away. */ | |
| 120 | 199 | function AskG1t({ slug, available, error }: { slug: string; available: boolean; error: string | null }) { | |
| 121 | 200 | const navigation = useNavigation(); | |
| 122 | − | const here = `/${slug}/-/today`; | |
| 201 | + | const here = homePagePath(slug); | |
| 123 | 202 | const busy = navigation.state !== "idle" && navigation.formAction === here; | |
| 124 | 203 | const [text, setText] = useState(""); | |
| 125 | 204 | return ( | |
| ⋯ | |||
| 158 | 237 | ); | |
| 159 | 238 | } | |
| 160 | 239 | ||
| 161 | − | export default function Today({ loaderData, actionData }: Route.ComponentProps) { | |
| 162 | − | const { slug, now, date, orchestrator, day, attention: waiting, head, spend } = loaderData; | |
| 240 | + | /** How long the page must have been in view before it counts as a visit. */ | |
| 241 | + | const SEEN_AFTER_MS = 5_000; | |
| 242 | + | ||
| 243 | + | /** | |
| 244 | + | * Marks this visit once the page has been in view for a few seconds, with | |
| 245 | + | * the time it loaded: what happened after that shows next time. Never on | |
| 246 | + | * first paint, so a quick refresh keeps what was not looked at yet. | |
| 247 | + | */ | |
| 248 | + | function useMarkSeen(slug: string, loadedAt: number) { | |
| 249 | + | useEffect(() => { | |
| 250 | + | let timer: ReturnType<typeof setTimeout> | null = null; | |
| 251 | + | let done = false; | |
| 252 | + | const mark = () => { | |
| 253 | + | if (done) return; | |
| 254 | + | done = true; | |
| 255 | + | void fetch(`${homePagePath(slug)}/seen`, { | |
| 256 | + | method: "POST", | |
| 257 | + | headers: { "content-type": "application/json" }, | |
| 258 | + | body: JSON.stringify({ at: loadedAt }), | |
| 259 | + | keepalive: true, | |
| 260 | + | }).catch(() => undefined); | |
| 261 | + | }; | |
| 262 | + | const arm = () => { | |
| 263 | + | if (timer) clearTimeout(timer); | |
| 264 | + | timer = document.visibilityState === "visible" ? setTimeout(mark, SEEN_AFTER_MS) : null; | |
| 265 | + | }; | |
| 266 | + | arm(); | |
| 267 | + | document.addEventListener("visibilitychange", arm); | |
| 268 | + | return () => { | |
| 269 | + | document.removeEventListener("visibilitychange", arm); | |
| 270 | + | if (timer) clearTimeout(timer); | |
| 271 | + | }; | |
| 272 | + | }, [slug, loadedAt]); | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | export default function Home({ loaderData, actionData }: Route.ComponentProps) { | |
| 276 | + | const { slug, now, tz, span, orchestrator, work, happened, attention: waiting, running: runningNow, head, spend } = loaderData; | |
| 277 | + | useMarkSeen(slug, now); | |
| 163 | 278 | return ( | |
| 164 | 279 | <div className="mx-auto w-full max-w-[1040px] space-y-5 px-4 py-5 md:px-10 md:py-8"> | |
| 165 | 280 | <header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> | |
| 166 | 281 | <div className="min-w-0"> | |
| 167 | − | <h1 className="text-[1.75rem] leading-tight font-semibold tracking-tight">Today</h1> | |
| 282 | + | <h1 className="text-[1.75rem] leading-tight font-semibold tracking-tight">{span.key === "last" && span.note == null ? "Welcome back" : "Home"}</h1> | |
| 168 | 283 | <p className="mt-1.5 max-w-2xl text-sm leading-relaxed text-muted"> | |
| 169 | − | <span className="text-fg-soft">{date}</span> ·{" "} | |
| 170 | 284 | <Suspense fallback={<Skeleton className="inline-block h-3.5 w-72 align-middle" />}> | |
| 171 | 285 | <Await resolve={head}> | |
| 172 | − | {([work, att]) => ( | |
| 286 | + | {([work, happened, att]) => ( | |
| 173 | 287 | <> | |
| 174 | − | {work ? daySentence(work) : "Today's agent work couldn't be read."} {waitingSentence(att.rows.length)} | |
| 288 | + | <span className="text-fg-soft">{waitingSentence(att.rows.length)}</span> {spanSentence(span, work, happened?.landed?.length ?? null)} | |
| 175 | 289 | </> | |
| 176 | 290 | )} | |
| 177 | 291 | </Await> | |
| 178 | 292 | </Suspense> | |
| 179 | 293 | </p> | |
| 180 | 294 | </div> | |
| 181 | − | <Suspense fallback={<ReviewButton count={null} />}> | |
| 182 | − | <Await resolve={waiting}>{(att) => <ReviewButton count={att.rows.length} />}</Await> | |
| 183 | − | </Suspense> | |
| 295 | + | <WindowSwitch span={span} /> | |
| 184 | 296 | </header> | |
| 185 | 297 | ||
| 186 | 298 | <AskG1t slug={slug} available={orchestrator} error={actionData?.error ?? null} /> | |
| 187 | 299 | ||
| 188 | − | <Suspense fallback={<DaySkeleton />}> | |
| 189 | − | <Await resolve={day}>{(work) => <DayCard work={work} />}</Await> | |
| 190 | − | </Suspense> | |
| 191 | − | ||
| 192 | − | {/* Phones put the one place to start first; wide screens keep it beside the list. */} | |
| 193 | − | <div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]"> | |
| 194 | − | <div className="lg:col-start-1 lg:row-span-2 lg:row-start-1"> | |
| 195 | − | <Card title="Needs attention" link={{ label: "Notifications", to: "/notifications" }}> | |
| 196 | − | <Suspense fallback={<RowsSkeleton />}> | |
| 197 | − | <Await resolve={waiting}>{(att) => <AttentionList attention={att} />}</Await> | |
| 300 | + | {/* 1. Where you're needed now: no window, first and biggest. Start here beside it on wide screens, under it on phones. */} | |
| 301 | + | <div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]"> | |
| 302 | + | <div className="lg:col-start-1 lg:row-start-1"> | |
| 303 | + | <Card | |
| 304 | + | title="Where you're needed" | |
| 305 | + | hint="Everything waiting on you, however long ago it started" | |
| 306 | + | link={{ label: "Notifications", to: "/notifications" }} | |
| 307 | + | > | |
| 308 | + | <Suspense fallback={<RowsSkeleton rows={4} />}> | |
| 309 | + | <Await resolve={waiting}>{(att) => <NeedsList attention={att} now={now} />}</Await> | |
| 198 | 310 | </Suspense> | |
| 199 | − | <Suspense fallback={null}> | |
| 200 | − | <Await resolve={day}>{(work) => <Sources work={work} />}</Await> | |
| 201 | − | </Suspense> | |
| 202 | 311 | </Card> | |
| 203 | 312 | </div> | |
| 204 | − | <div className="order-first lg:order-none lg:col-start-2 lg:row-start-2"> | |
| 313 | + | <div className="lg:col-start-2 lg:row-start-1"> | |
| 205 | 314 | <Suspense | |
| 206 | 315 | fallback={ | |
| 207 | 316 | <Card title="Start here"> | |
| ⋯ | |||
| 212 | 321 | <Await resolve={waiting}>{(att) => <StartHere attention={att} now={now} />}</Await> | |
| 213 | 322 | </Suspense> | |
| 214 | 323 | </div> | |
| 215 | − | <div className="lg:col-start-2 lg:row-start-1"> | |
| 216 | − | <Suspense | |
| 217 | − | fallback={ | |
| 218 | − | <Card title="Spent today"> | |
| 219 | − | <SpendSkeleton /> | |
| 220 | − | </Card> | |
| 221 | − | } | |
| 222 | − | > | |
| 223 | − | <Await resolve={spend}>{(value) => <SpendCard spend={value} slug={slug} />}</Await> | |
| 224 | − | </Suspense> | |
| 324 | + | </div> | |
| 325 | + | ||
| 326 | + | {/* 2. What happened in the span. */} | |
| 327 | + | <section aria-labelledby="since" className="space-y-4"> | |
| 328 | + | <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 pt-2"> | |
| 329 | + | <h2 id="since" className="text-lg font-semibold tracking-tight text-fg"> | |
| 330 | + | {span.key === "last" && span.note == null ? "Since you were last here" : span.words} | |
| 331 | + | </h2> | |
| 332 | + | <p className="text-sm text-muted"> | |
| 333 | + | {span.key === "last" && span.note == null ? `${span.words} · ${span.length}` : spanNote(span.note)} | |
| 334 | + | </p> | |
| 225 | 335 | </div> | |
| 226 | − | </div> | |
| 336 | + | <Suspense fallback={<SpanSkeleton />}> | |
| 337 | + | <Await resolve={work}>{(value) => <SpanCard work={value} span={span} />}</Await> | |
| 338 | + | </Suspense> | |
| 339 | + | <div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]"> | |
| 340 | + | <div className="space-y-5"> | |
| 341 | + | <Suspense | |
| 342 | + | fallback={ | |
| 343 | + | <Card title="Landed"> | |
| 344 | + | <RowsSkeleton rows={3} /> | |
| 345 | + | </Card> | |
| 346 | + | } | |
| 347 | + | > | |
| 348 | + | <Await resolve={happened}>{(value) => <LandedCard landed={value?.landed ?? null} code={loaderData.code} now={now} tz={tz} />}</Await> | |
| 349 | + | </Suspense> | |
| 350 | + | <Suspense fallback={null}> | |
| 351 | + | <Await resolve={happened}> | |
| 352 | + | {(value) => <DeploysCard deploys={value?.deploys ?? null} complete={value?.deploysComplete ?? true} code={loaderData.code} now={now} tz={tz} />} | |
| 353 | + | </Await> | |
| 354 | + | </Suspense> | |
| 355 | + | <Suspense fallback={null}> | |
| 356 | + | <Await resolve={happened}> | |
| 357 | + | {(value) => <DecisionsCard decisions={value?.decisions ?? null} read={value?.decisionsRead ?? null} now={now} tz={tz} />} | |
| 358 | + | </Await> | |
| 359 | + | </Suspense> | |
| 360 | + | </div> | |
| 361 | + | <div className="space-y-5"> | |
| 362 | + | <Suspense | |
| 363 | + | fallback={ | |
| 364 | + | <Card title="Spent"> | |
| 365 | + | <SpendSkeleton /> | |
| 366 | + | </Card> | |
| 367 | + | } | |
| 368 | + | > | |
| 369 | + | <Await resolve={spend}>{(value) => <SpendCard spend={value} slug={slug} span={span} />}</Await> | |
| 370 | + | </Suspense> | |
| 371 | + | <Suspense fallback={null}> | |
| 372 | + | <Await resolve={work}>{(value) => <Sources work={value} />}</Await> | |
| 373 | + | </Suspense> | |
| 374 | + | </div> | |
| 375 | + | </div> | |
| 376 | + | </section> | |
| 377 | + | ||
| 378 | + | {/* 3. Running now. */} | |
| 379 | + | <Card title="Running now" link={{ label: "Agents", to: `/${slug}/-/agents` }}> | |
| 380 | + | <Suspense fallback={<RowsSkeleton rows={2} />}> | |
| 381 | + | <Await resolve={runningNow}>{(value) => <RunningList rows={value.rows} missing={value.missing} now={now} />}</Await> | |
| 382 | + | </Suspense> | |
| 383 | + | </Card> | |
| 227 | 384 | </div> | |
| 228 | 385 | ); | |
| 229 | 386 | } | |
| 230 | 387 | ||
| 231 | − | /** What's waiting, one click away: loud while there is something, quiet when there isn't. */ | |
| 232 | − | function ReviewButton({ count }: { count: number | null }) { | |
| 233 | − | return ( | |
| 234 | − | <Link | |
| 235 | − | to="/notifications" | |
| 236 | − | className={`inline-flex h-9 shrink-0 items-center gap-1.5 self-start rounded-lg px-3.5 text-sm font-medium transition-colors ${ | |
| 237 | − | count ? "bg-accent text-bg hover:bg-accent-hover" : "border border-line text-fg/90 hover:border-line-strong hover:bg-raised" | |
| 238 | − | }`} | |
| 239 | − | > | |
| 240 | − | {count ? `Review ${count.toLocaleString("en-US")}` : "Notifications"} | |
| 241 | − | <ArrowRight size={14} /> | |
| 242 | − | </Link> | |
| 243 | − | ); | |
| 388 | + | function spanNote(note: "first" | "capped" | "unknown" | null): string { | |
| 389 | + | if (note === "first") return "Your first visit here, so this is the last 24 hours."; | |
| 390 | + | if (note === "capped") return "You were away longer than that; this is as far back as Home looks."; | |
| 391 | + | if (note === "unknown") return "Your last visit couldn't be read, so this is the last 24 hours."; | |
| 392 | + | return ""; | |
| 244 | 393 | } | |
| 1 | 1 | /** | |
| 2 | − | * Workspace pages that moved: `-/home` is Today now, and `-/overview` | |
| 2 | + | * Workspace pages that moved: `-/today` is Home now, and `-/overview` | |
| 3 | 3 | * the workspace's own page (lib/workspace-nav.ts). | |
| 4 | 4 | */ | |
| 5 | 5 | import { data, redirect } from "react-router"; |
| 30 | 30 | const viewer = getViewer(context); | |
| 31 | 31 | const slug = params.owner.toLowerCase(); | |
| 32 | 32 | const role = roleIn(viewer, slug); | |
| 33 | − | // A member's way in is Today; this page | |
| 33 | + | // A member's way in is Home; this page | |
| 34 | 34 | // is the workspace as a visitor sees it. | |
| 35 | − | if (role) throw redirect(`/${slug}/-/today`); | |
| 35 | + | if (role) throw redirect(`/${slug}/-/home`); | |
| 36 | 36 | const [listed, members, deploys, usage, published, shortcuts] = await Promise.all([ | |
| 37 | 37 | // The listing the sidebar and the Projects tab share. | |
| 38 | 38 | workspaceProjects(slug, viewer), |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/spend"; | |
| 8 | 8 | import { answer, agentsAction } from "../../components/agents/actions.server"; | |
| 9 | − | import { RowsSkeleton } from "../../components/today"; | |
| 9 | + | import { RowsSkeleton } from "../../components/home"; | |
| 10 | 10 | import { | |
| 11 | 11 | BudgetLadder, | |
| 12 | 12 | ComingNote, |
| 259 | 259 | * Someone the room has never heard from is not listed: they are offline. | |
| 260 | 260 | */ | |
| 261 | 261 | workspacePresence(workspace: string, userIds?: string[] | null): Promise<PresenceEntry[]>; | |
| 262 | + | /** | |
| 263 | + | * When the person was last on the workspace's Home page (RFC 3339), or | |
| 264 | + | * null when they never were. Kept with them, so it is the same on every | |
| 265 | + | * device. | |
| 266 | + | */ | |
| 267 | + | lastVisit(user: Pick<User, "id">, workspace: string): Promise<{ seen_at: string | null }>; | |
| 268 | + | /** | |
| 269 | + | * Marks that visit at `at` (RFC 3339): Home sends the time the page | |
| 270 | + | * loaded, once the person has looked at it. It only moves forward, never | |
| 271 | + | * past now nor more than a day behind it. | |
| 272 | + | */ | |
| 273 | + | markVisit(user: Pick<User, "id">, workspace: string, at: string): Promise<{ seen_at: string | null }>; | |
| 262 | 274 | }; | |
| 263 | 275 | ||
| 264 | 276 | async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> { | |
| ⋯ | |||
| 284 | 296 | presence: (user) => call("presence", { user_id: user.id, username: user.username }), | |
| 285 | 297 | setPresence: (user, change) => call("set_presence", { user_id: user.id, username: user.username, change }), | |
| 286 | 298 | workspacePresence: (workspace, userIds) => call("workspace_presence", { workspace, user_ids: userIds ?? null }), | |
| 299 | + | lastVisit: (user, workspace) => call("last_visit", { user_id: user.id, workspace }), | |
| 300 | + | markVisit: (user, workspace, at) => call("mark_visit", { user_id: user.id, workspace, at }), | |
| 287 | 301 | }; | |
| 288 | 302 | } | |
| 10 | 10 | * | |
| 11 | 11 | * Kept in the object's own SQLite storage: the latest notifications, the | |
| 12 | 12 | * unread counts per conversation, push subscriptions and preferences, and | |
| 13 | − | * the person's status, Do Not Disturb and whether they set themselves away. | |
| 13 | + | * the person's status, Do Not Disturb and whether they set themselves away, | |
| 14 | + | * and when they were last on each workspace's Home page (src/visits.ts). | |
| 14 | 15 | * | |
| 15 | 16 | * Presence is worked out here from the tabs (src/presence.ts) and told to | |
| 16 | 17 | * the room of every workspace the person belongs to (src/room.ts), which | |
| ⋯ | |||
| 54 | 55 | type Kept, | |
| 55 | 56 | } from "./presence.ts"; | |
| 56 | 57 | import type { Room } from "./room.ts"; | |
| 58 | + | import { type Visit, lastVisit, markVisit } from "./visits.ts"; | |
| 57 | 59 | import { sendPush, type Vapid } from "./webpush.ts"; | |
| 58 | 60 | ||
| 59 | 61 | export type FeedEnv = { | |
| ⋯ | |||
| 97 | 99 | `CREATE TABLE IF NOT EXISTS subscriptions (endpoint TEXT PRIMARY KEY, p256dh TEXT NOT NULL, auth TEXT NOT NULL, user_agent TEXT, created_at INTEGER NOT NULL)`, | |
| 98 | 100 | ); | |
| 99 | 101 | this.sql.exec(`CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`); | |
| 102 | + | // When the person was last on each workspace's Home page (src/visits.ts). | |
| 103 | + | this.sql.exec(`CREATE TABLE IF NOT EXISTS visits (workspace TEXT PRIMARY KEY, seen_at INTEGER NOT NULL, previous_at INTEGER)`); | |
| 100 | 104 | } | |
| 101 | 105 | ||
| 102 | 106 | // ── Kept state ────────────────────────────────────────────────────────── | |
| ⋯ | |||
| 552 | 556 | if (person.username) this.setMeta("username", person.username.slice(0, 100)); | |
| 553 | 557 | } | |
| 554 | 558 | ||
| 559 | + | private visit(workspace: string): Visit | null { | |
| 560 | + | return this.sql.exec<Visit>("SELECT seen_at, previous_at FROM visits WHERE workspace = ?", workspace).toArray()[0] ?? null; | |
| 561 | + | } | |
| 562 | + | ||
| 563 | + | /** When the person was last on `workspace`'s Home page, as it counts from (src/visits.ts); null when never. */ | |
| 564 | + | async lastVisit(workspace: string): Promise<{ seen_at: string | null }> { | |
| 565 | + | const since = lastVisit(this.visit(workspace), Date.now()); | |
| 566 | + | return { seen_at: since != null ? new Date(since).toISOString() : null }; | |
| 567 | + | } | |
| 568 | + | ||
| 569 | + | /** Marks the person's visit to `workspace`'s Home page at `at`; it only moves forward. */ | |
| 570 | + | async markVisit(workspace: string, at: unknown): Promise<{ seen_at: string | null }> { | |
| 571 | + | const now = Date.now(); | |
| 572 | + | const next = markVisit(this.visit(workspace), at, now); | |
| 573 | + | if (next) { | |
| 574 | + | this.sql.exec( | |
| 575 | + | "INSERT INTO visits (workspace, seen_at, previous_at) VALUES (?, ?, ?) ON CONFLICT (workspace) DO UPDATE SET seen_at = excluded.seen_at, previous_at = excluded.previous_at", | |
| 576 | + | workspace, | |
| 577 | + | next.seen_at, | |
| 578 | + | next.previous_at, | |
| 579 | + | ); | |
| 580 | + | } | |
| 581 | + | return this.lastVisit(workspace); | |
| 582 | + | } | |
| 583 | + | ||
| 555 | 584 | async test(username: string): Promise<{ ok: boolean; pushed: number }> { | |
| 556 | 585 | const pushed = await this.tell( | |
| 557 | 586 | { | |
| 13 | 13 | * - events: `notify`, for every inbox item, and `set_inbox`, the count after | |
| 14 | 14 | * items arrive or are marked, naming the person by username; | |
| 15 | 15 | * - the site: `subscribe`, `unsubscribe`, `status`, `set_preferences`, | |
| 16 | − | * `test`, `presence` and `set_presence`, for the person signed in; | |
| 16 | + | * `test`, `presence` and `set_presence`, for the person signed in; and | |
| 17 | + | * `last_visit` and `mark_visit`, when they were last on a workspace's | |
| 18 | + | * Home page (src/visits.ts); | |
| 17 | 19 | * - agents and the site: `workspace_presence`, how a workspace's people | |
| 18 | 20 | * show now (away, in Do Not Disturb, offline, and their statuses); | |
| 19 | 21 | * - integrations, later: `set_presence` with a status of `source` | |
| ⋯ | |||
| 33 | 35 | ||
| 34 | 36 | import { FEED_USERNAME_HEADER, FEED_USER_ID_HEADER, Feed, type FeedEnv } from "./feed.ts"; | |
| 35 | 37 | import { onlyPeople } from "./presence.ts"; | |
| 38 | + | import { visitWorkspace } from "./visits.ts"; | |
| 36 | 39 | ||
| 37 | 40 | export { Feed } from "./feed.ts"; | |
| 38 | 41 | export { Room } from "./room.ts"; | |
| ⋯ | |||
| 97 | 100 | return Response.json(await stub.status(typeof args.endpoint === "string" ? args.endpoint : null)); | |
| 98 | 101 | case "set_preferences": | |
| 99 | 102 | return Response.json(await stub.setPreferences(args.preferences)); | |
| 103 | + | case "last_visit": | |
| 104 | + | case "mark_visit": { | |
| 105 | + | const workspace = visitWorkspace(args.workspace); | |
| 106 | + | if (!workspace) return Response.json({ seen_at: null }); | |
| 107 | + | return Response.json(method === "last_visit" ? await stub.lastVisit(workspace) : await stub.markVisit(workspace, args.at)); | |
| 108 | + | } | |
| 100 | 109 | case "test": | |
| 101 | 110 | return Response.json(await stub.test(typeof args.username === "string" ? args.username : "")); | |
| 102 | 111 | case "presence": | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { VISIT_GAP_MS, lastVisit, markVisit, visitWorkspace } from "./visits.ts"; | |
| 5 | + | ||
| 6 | + | const NOW = Date.parse("2026-10-10T12:00:00Z"); | |
| 7 | + | const MIN = 60_000; | |
| 8 | + | const HOUR = 60 * MIN; | |
| 9 | + | const DAY = 24 * HOUR; | |
| 10 | + | ||
| 11 | + | test("a workspace slug is lowercased and checked", () => { | |
| 12 | + | assert.equal(visitWorkspace(" Acme "), "acme"); | |
| 13 | + | assert.equal(visitWorkspace("flagon-io"), "flagon-io"); | |
| 14 | + | assert.equal(visitWorkspace(""), null); | |
| 15 | + | assert.equal(visitWorkspace("a/b"), null); | |
| 16 | + | assert.equal(visitWorkspace(7), null); | |
| 17 | + | assert.equal(visitWorkspace("x".repeat(65)), null); | |
| 18 | + | }); | |
| 19 | + | ||
| 20 | + | test("the first mark starts a visit with nothing before it", () => { | |
| 21 | + | assert.deepEqual(markVisit(null, new Date(NOW - HOUR).toISOString(), NOW), { seen_at: NOW - HOUR, previous_at: null }); | |
| 22 | + | }); | |
| 23 | + | ||
| 24 | + | test("marks close together are one visit; a gap starts the next", () => { | |
| 25 | + | const first = { seen_at: NOW - 3 * DAY, previous_at: null }; | |
| 26 | + | // Back after three days: the new visit counts from the old one's last mark. | |
| 27 | + | const back = markVisit(first, NOW - 10 * MIN, NOW)!; | |
| 28 | + | assert.deepEqual(back, { seen_at: NOW - 10 * MIN, previous_at: NOW - 3 * DAY }); | |
| 29 | + | // A refresh a few minutes later is the same visit. | |
| 30 | + | assert.deepEqual(markVisit(back, NOW, NOW), { seen_at: NOW, previous_at: NOW - 3 * DAY }); | |
| 31 | + | }); | |
| 32 | + | ||
| 33 | + | test("a mark never moves the visit back", () => { | |
| 34 | + | const kept = { seen_at: NOW - HOUR, previous_at: null }; | |
| 35 | + | assert.equal(markVisit(kept, NOW - 2 * HOUR, NOW), null); | |
| 36 | + | assert.equal(markVisit(kept, NOW - HOUR, NOW), null); | |
| 37 | + | }); | |
| 38 | + | ||
| 39 | + | test("a mark is never in the future, nor more than a day behind", () => { | |
| 40 | + | assert.equal(markVisit(null, NOW + HOUR, NOW)!.seen_at, NOW); | |
| 41 | + | assert.equal(markVisit(null, NOW - 72 * HOUR, NOW)!.seen_at, NOW - DAY); | |
| 42 | + | assert.equal(markVisit(null, "not a time", NOW), null); | |
| 43 | + | assert.equal(markVisit(null, undefined, NOW), null); | |
| 44 | + | }); | |
| 45 | + | ||
| 46 | + | test("last here: the visit before during one, its last mark after", () => { | |
| 47 | + | assert.equal(lastVisit(null, NOW), null); | |
| 48 | + | const during = { seen_at: NOW - 5 * MIN, previous_at: NOW - 3 * DAY }; | |
| 49 | + | assert.equal(lastVisit(during, NOW), NOW - 3 * DAY); | |
| 50 | + | assert.equal(lastVisit({ seen_at: NOW - 5 * MIN, previous_at: null }, NOW), null); | |
| 51 | + | assert.equal(lastVisit(during, NOW - 5 * MIN + VISIT_GAP_MS), NOW - 5 * MIN); | |
| 52 | + | }); |
| 1 | + | /** | |
| 2 | + | * When each person was last on a workspace's Home page, kept in their own | |
| 3 | + | * feed (src/feed.ts) so it is the same on every device. Home reads it to | |
| 4 | + | * show what happened while they were away, and marks it once they have | |
| 5 | + | * looked (never on first paint), so a quick refresh does not wipe what | |
| 6 | + | * they have not seen. | |
| 7 | + | * | |
| 8 | + | * Marks close together are one visit: a refresh, or coming back to Home | |
| 9 | + | * from another page a few minutes later, keeps showing what happened | |
| 10 | + | * before the visit began. A visit ends once no mark has come for | |
| 11 | + | * `VISIT_GAP_MS`; the next one shows what happened since its last mark. | |
| 12 | + | */ | |
| 13 | + | ||
| 14 | + | /** The longest slug a visit is kept for. */ | |
| 15 | + | const MAX_SLUG = 64; | |
| 16 | + | /** How far behind now a mark may be: Home marks the time the page loaded, at most a day before. */ | |
| 17 | + | const MAX_BEHIND_MS = 24 * 60 * 60 * 1000; | |
| 18 | + | /** Marks closer than this are one visit. */ | |
| 19 | + | export const VISIT_GAP_MS = 30 * 60 * 1000; | |
| 20 | + | ||
| 21 | + | /** What is kept per workspace: the latest mark, and the last mark of the visit before this one. */ | |
| 22 | + | export type Visit = { seen_at: number; previous_at: number | null }; | |
| 23 | + | ||
| 24 | + | /** A workspace slug as visits key it, or null when it is not one. */ | |
| 25 | + | export function visitWorkspace(value: unknown): string | null { | |
| 26 | + | if (typeof value !== "string") return null; | |
| 27 | + | const slug = value.trim().toLowerCase(); | |
| 28 | + | return slug && slug.length <= MAX_SLUG && /^[a-z0-9][a-z0-9-]*$/.test(slug) ? slug : null; | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /** | |
| 32 | + | * The visit to keep after a mark at `at` (RFC 3339 or epoch ms): never | |
| 33 | + | * later than now, never more than a day behind it, and never earlier than | |
| 34 | + | * what is kept, so an old tab marking late does not move it back. Null | |
| 35 | + | * when there is nothing to change. | |
| 36 | + | */ | |
| 37 | + | export function markVisit(kept: Visit | null, at: unknown, now: number): Visit | null { | |
| 38 | + | const given = typeof at === "string" ? Date.parse(at) : typeof at === "number" ? at : Number.NaN; | |
| 39 | + | if (!Number.isFinite(given)) return null; | |
| 40 | + | const mark = Math.min(now, Math.max(given, now - MAX_BEHIND_MS)); | |
| 41 | + | if (!kept) return { seen_at: mark, previous_at: null }; | |
| 42 | + | if (kept.seen_at >= mark) return null; | |
| 43 | + | // The same visit: what it shows still starts where it began. | |
| 44 | + | if (mark - kept.seen_at < VISIT_GAP_MS) return { seen_at: mark, previous_at: kept.previous_at }; | |
| 45 | + | return { seen_at: mark, previous_at: kept.seen_at }; | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /** | |
| 49 | + | * When the person was last here, as Home counts from: during a visit, the | |
| 50 | + | * end of the one before it; after one, its last mark. Null when there was | |
| 51 | + | * none before. | |
| 52 | + | */ | |
| 53 | + | export function lastVisit(kept: Visit | null, now: number): number | null { | |
| 54 | + | if (!kept) return null; | |
| 55 | + | return now - kept.seen_at < VISIT_GAP_MS ? kept.previous_at : kept.seen_at; | |
| 56 | + | } |