Skip to content

Commit

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.

syntaqxcommitted Parent64a6ce1Browse files
39 files+3322−19950/39 viewed
+4−4
44 are members of the same workspace: they talk in channels and direct
55 messages and sit on the same teams. You hand an agent work, it does it, and
66 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
99 be reviewed, tested and shipped. Everything specific to your business will
1010 come from extensions and integrations.
1111
3232
3333 | Feature | Status |
3434 | --- | --- |
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 |
3737 | Chat: channels, direct messages and threads, live, with people and agents as members | Live |
3838 | Notifications: mentions, reviews, approvals and alerts, with browser push | Live |
3939 | People and teams: members, roles, nested teams, invites | Live |
+2−1
1313 redirects: {
1414 '/guides/g1t-agents/': '/guides/working-with-g1t/',
1515 '/guides/inbox/': '/guides/notifications/',
16+ '/guides/today/': '/guides/home/',
1617 },
1718 integrations: [
1819 starlight({
7879 items: [
7980 { label: 'Quickstart', slug: 'quickstart' },
8081 { label: 'How g1t works', slug: 'concepts/overview' },
81− { label: 'Today', slug: 'guides/today' },
82+ { label: 'Home', slug: 'guides/home' },
8283 { label: 'Notifications', slug: 'guides/notifications' },
8384 { label: 'Search and Explore', slug: 'guides/search' },
8485 { label: 'Status and incidents', slug: 'guides/status' },
+1−1
182182
183183 | | |
184184 | --- | --- |
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. |
186186 | **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. |
187187 | **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. |
188188 | **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. |
+228−0
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.
+2−2
241241 **Mark all read** marks everything under the tab you are on as read.
242242 **Open Notifications**, at the foot of the panel, goes to the page.
243243
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.
246246
247247 ## From the API and agents
248248
+0−174
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.
+5−5
537537
538538 | Part | Where | What it holds |
539539 | --- | --- | --- |
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. |
542542 | **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. |
543543
544544 ### The dock
548548
549549 | App | Opens |
550550 | --- | --- |
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/). |
552552 | **Chat** | Channels and messages. See [Chat](/guides/chat/). |
553553 | **Notifications** | Reviews, mentions, failures and what agents wait on. See [notifications](/guides/notifications/). |
554554 | **Agents** | The workspace's agents and their sessions. See [agents](/guides/agents/). |
701701
702702 | | What it does |
703703 | --- | --- |
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. |
705705 | **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. |
706706 | **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. |
707707 | **The tab you are already on** | Tap it again to open that app's sidebar too. |
734734 Mission control is Code's **Overview**, at `g1t.sh/<workspace>/-/overview`.
735735 It shows where you are needed in the workspace's code, what its agents are
736736 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.
738738
739739 Under the greeting, one line sums up the week, such as *Agents landed 37
740740 of their 39 changes this week without you, and people landed 8 changes of
+4−4
1515 agents are members of the same workspace: they talk in channels and direct
1616 messages, and they sit on the same teams. You hand an agent work, it does it
1717 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,
2020 because what agents make has to be reviewed, tested and shipped somewhere.
2121
2222 g1t is MIT-licensed. You can use it on g1t.sh, or run it yourself.
5555 ## The workspace
5656
5757 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
5959 Artifacts, the apps you pin, and People, Workspace and your account at its
6060 foot. On a phone it is a bar along the bottom.
6161
6262 | Feature | What it does | Status |
6363 | --- | --- | --- |
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" /> |
6565 | [Chat](/guides/chat/) | Channels, direct messages and threads, live. People and agents are members alike. No seats and no history limit. | <Status is="live" /> |
6666 | [Notifications](/guides/notifications/) | Everything waiting on you: mentions, reviews, approvals and alerts, with browser push. | <Status is="live" /> |
6767 | [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" /> |
+2−2
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";
22 import { type ReactNode, useEffect, useMemo, useState } from "react";
33 import { Link, useFetcher, useFetchers } from "react-router";
44
1212 /** Each built-in app's icon, at the size asked for. */
1313 export function appIcon(key: BuiltinApp, size = 18): ReactNode {
1414 const icons: Record<BuiltinApp, ReactNode> = {
15− today: <Sun size={size} />,
15+ home: <House size={size} />,
1616 chat: <MessagesSquare size={size} />,
1717 notifications: <Bell size={size} />,
1818 agents: <Sparkles size={size} />,
+2−2
1313 GitMerge,
1414 GitPullRequest,
1515 Hash,
16− Sun,
16+ House,
1717 Bell,
1818 MessagesSquare,
1919 Paperclip,
114114 }
115115
116116 const RAIL: { icon: ReactNode; label: string; on?: boolean }[] = [
117− { icon: <Sun size={16} />, label: "Today" },
117+ { icon: <House size={16} />, label: "Home" },
118118 { icon: <Code2 size={16} />, label: "Code" },
119119 { icon: <MessagesSquare size={16} />, label: "Chat", on: true },
120120 { icon: <BookOpen size={16} />, label: "Docs" },
+6−6
2121 import { type BuiltinApp, type PinnableApp } from "../lib/apps";
2222 import { cn } from "../lib/cn";
2323 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";
2525
2626 /** The dock's column: 8px from the window's edge, 80px wide. */
2727 export const DOCK_COLUMN = "5.5rem";
143143 <DropdownMenuLabel>Switch workspace</DropdownMenuLabel>
144144 {(user.workspaces ?? []).map((membership) => (
145145 <DropdownMenuItem key={membership.slug} asChild>
146− <Link to={todayPath(membership.slug)}>
146+ <Link to={homePagePath(membership.slug)}>
147147 <Avatar name={membership.slug} image={membership.avatar} size={24} square />
148148 <span className="flex min-w-0 grow flex-col leading-tight">
149149 <span className="truncate">{displayName(membership)}</span>
244244
245245 /**
246246 * 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,
248248 * Code and Artifacts) with their names; the apps you pinned, as icons;
249249 * the Apps launcher; and at its foot People, Workspace and your account.
250250 * What is lit follows the address. A member without Code access has no
270270 const code = hasCodeAccess(workspace);
271271 const { pins, toggle } = useAppPins(slug, saved);
272272 const builtins: Item[] = [
273− { key: "today", label: "Today" },
273+ { key: "home", label: "Home" },
274274 {
275275 key: "chat",
276276 label: "Chat",
290290 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)]"
291291 >
292292 <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">
295295 <Mark tight className="h-[1.125rem] w-auto" />
296296 </Link>
297297 </Hint>
+739−0
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&apos;t answer, so {what} isn&apos;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&apos; work couldn&apos;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&apos;t answer, so what landed can&apos;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&apos;t answer, so builds can&apos;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&apos; builds couldn&apos;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&apos;t answer, so spend can&apos;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+}
+2−2
119119 type Tab = { key: ModeKey; label: string; badge?: { count: number; loud: boolean } };
120120
121121 /**
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,
123123 * Notifications, Agents and Code (for a member with Code access), each
124124 * with what is unread, and More. It steps aside while the keyboard is up
125125 * and inside a conversation. The tab you are in, tapped again, opens its
147147 const code = hasCodeAccess(workspace);
148148 const mode = modeOf(pathname, slug);
149149 const tabs: Tab[] = [
150− { key: "today", label: "Today" },
150+ { key: "home", label: "Home" },
151151 { key: "chat", label: "Chat", badge: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 } },
152152 { key: "notifications", label: "Notifications", badge: { count: unread.notifications, loud: true } },
153153 { key: "agents", label: "Agents" },
+4−4
1313 * per step, chosen with the pills.
1414 *
1515 * 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,
1717 * with names), each mode's sidebar with its real sections and words, the
1818 * same top bar (where you are, Explore, Docs, Ask g1t, the inbox and +),
1919 * which Chat gives up to the conversation's own header as the app does,
4444 GitMerge,
4545 GitPullRequest,
4646 Hash,
47− Sun,
47+ House,
4848 MessagesSquare,
4949 Network,
5050 Paperclip,
348348
349349 /** The rail's modes, in the app's order (components/rail.tsx), with its icons. */
350350 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" },
352352 { key: "code", icon: <Code2 size={15} />, label: "Code", target: "rail-code", scene: "code" },
353353 { key: "chat", icon: <MessagesSquare size={15} />, label: "Chat", target: "rail-chat", scene: "chat", badge: 3 },
354354 { key: "artifacts", icon: <Shapes size={15} />, label: "Artifacts", target: "rail-docs", scene: "docs" },
12861286
12871287 /** The phone's tab bar, as the app has it (components/mobile.tsx). */
12881288 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" },
12901290 { scene: "code", icon: <Code2 size={16} />, label: "Code" },
12911291 { scene: "chat", icon: <MessagesSquare size={16} />, label: "Chat" },
12921292 { scene: "agents", icon: <Sparkles size={16} />, label: "Agents" },
+7−7
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";
22 import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
33 import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
44
2525 DropdownMenuTrigger,
2626 } from "./ui/dropdown-menu";
2727 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";
2929 import { AgentsSidebar } from "./agents-mode";
3030 import { ChatSidebar } from "./chat/sidebar";
3131 import { FoliosSidebar } from "./folios/sidebar";
14231423 sessions: "Sessions",
14241424 chat: "Chat",
14251425 artifacts: "Artifacts",
1426− today: "Today",
1426+ home: "Home",
14271427 apps: "Apps",
14281428 marketplace: "Marketplace",
14291429 "code-access": "Code access",
14551455 }
14561456 const parts = pathname.split("/").filter(Boolean);
14571457 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>;
14591459 // Your settings: Settings / Emails.
14601460 if (parts[0] === "settings") {
14611461 const page = accountSettingsPage(pathname);
15841584 const commands: Command[] = [
15851585 ...(ws
15861586 ? [
1587− { label: "Today", to: todayPath(ws.slug), icon: <Sun size={15} /> },
1587+ { label: "Home", to: homePagePath(ws.slug), icon: <House size={15} /> },
15881588 { label: "Chat", to: `/${ws.slug}/-/chat`, icon: <MessagesSquare size={15} /> },
15891589 { label: "Agents", to: `/${ws.slug}/-/agents`, icon: <Sparkles size={15} /> },
15901590 ...(hasCodeAccess(ws) ? [{ label: "Code overview", to: `/${ws.slug}/-/overview`, icon: <Code2 size={15} /> }] : []),
17421742 }
17431743
17441744 /**
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
17461746 * Apps are pages at full width, and so are g1t's public pages, such as a
17471747 * profile, which are no workspace's. Without a workspace, only your own
17481748 * settings and Notifications have one.
17491749 */
17501750 function sidebarFor(mode: ModeKey, workspace: boolean): Panel | null {
17511751 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;
17531753 return mode;
17541754 }
17551755
+0−485
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&apos;s agent work couldn&apos;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&apos;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&apos;t answer, so what waits there isn&apos;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&apos;s work came from</h3>
322− {!work ? (
323− <p className="mt-2 text-xs text-muted">Couldn&apos;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&apos;t answer, so today&apos;s spend can&apos;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&apos;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−}
+3−3
22 * The dock's built-in apps, the apps a workspace installed from the
33 * Marketplace, and the ones each person pins to their dock.
44 *
5− * Built-in apps (Today, Chat, Notifications, Agents, Code, Artifacts,
5+ * Built-in apps (Home, Chat, Notifications, Agents, Code, Artifacts,
66 * People and Workspace) are always in the dock; their own pages (Projects,
77 * Usage, Teams and the rest) are in their sidebars. Apps are what the
88 * workspace added from the Marketplace: today, the integrations it
2626 import { CONNECTOR_PUBLISHER, extensionById } from "@g1t/contracts/marketplace";
2727
2828 /** 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";
3030
3131 export type BuiltinInfo = {
3232 key: BuiltinApp;
4141
4242 /** The dock's built-in apps, in its order. */
4343 export const BUILTINS: BuiltinInfo[] = [
44− { key: "today", name: "Today", path: under("today") },
44+ { key: "home", name: "Home", path: under("home") },
4545 { key: "chat", name: "Chat", path: under("chat") },
4646 { key: "notifications", name: "Notifications", path: () => "/notifications" },
4747 { key: "agents", name: "Agents", path: under("agents") },
+1−1
3535 });
3636
3737 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"]) {
3939 assert.equal(frameOf(path, member), "app", path);
4040 assert.equal(frameOf(path, newcomer), "app", path);
4141 }
+170−0
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+}
+590−0
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+});
+1088−0
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+}
+1−1
119119 "user_for_session user_for_access_token usernames waiting_workspaces workflows workspace workspace_invites github_enabled " +
120120 "stars about public_links branch_drift tags last_commits languages contributors license releases release " +
121121 "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"
123123 ).split(" "),
124124 );
125125
+0−98
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−}
+0−361
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−});
+0−708
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−}
+12−10
2424 test("old addresses go to where their pages are now", () => {
2525 // Members and invites is a page of People's again.
2626 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);
2830 // Code's Overview lives at -/overview now: not an old address.
2931 assert.equal(workspaceRedirect("/acme/-/overview"), null);
3032 assert.equal(workspaceRedirect("/acme/-/soon/teams"), "/acme/-/teams");
5355 test("a click's data request is for the same page as a full load", () => {
5456 assert.equal(pagePath("/acme/-/insights.data"), "/acme/-/insights");
5557 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");
5860 });
5961
6062 test("an alias or old name leads to the same page under the workspace", () => {
7476
7577 test("the dock's mode follows the address", async () => {
7678 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");
8082 // g1t's own public pages light no mode.
8183 assert.equal(modeOf("/explore", "acme"), "site");
8284 assert.equal(modeOf("/u/ada", "acme"), "site");
111113 assert.equal(modeOf("/new", "acme"), "code");
112114 // Another workspace's chat is not this one's mode; its repositories are Code's.
113115 assert.equal(modeOf("/other/-/chat", "acme"), "code");
114− assert.equal(modeHome("today", "acme"), "/acme/-/today");
116+ assert.equal(modeHome("home", "acme"), "/acme/-/home");
115117 assert.equal(modeHome("notifications", "acme"), "/notifications");
116118 assert.equal(modeHome("people", "acme"), "/acme/-/people");
117119 assert.equal(modeHome("apps", "acme"), "/acme/-/apps");
118120 assert.equal(modeHome("code", "acme"), "/acme/-/overview");
119121 assert.equal(modeHome("workspace", "acme"), "/acme/-/workspace");
120− assert.equal(homePath("acme"), "/acme/-/today");
122+ assert.equal(homePath("acme"), "/acme/-/home");
121123 assert.equal(homePath("acme", "?agent=new"), "/acme/-/overview?agent=new");
122124 assert.equal(homePath("acme", "?tab=landed&_routes=x"), "/acme/-/overview?tab=landed");
123125 });
125127 test("a member without Code access is sent around Code's pages", async () => {
126128 const { codeGate } = await import("./workspace-nav.ts");
127129 const none = ["acme"];
128− assert.equal(codeGate("/", "", none, "acme"), "/acme/-/today");
130+ assert.equal(codeGate("/", "", none, "acme"), "/acme/-/home");
129131 assert.equal(codeGate("/", "", none, "other"), null);
130− assert.equal(codeGate("/acme", "", none, "acme"), "/acme/-/today");
132+ assert.equal(codeGate("/acme", "", none, "acme"), "/acme/-/home");
131133 assert.equal(codeGate("/acme/web/pull/3", "?x=1", none, "acme"), "/acme/-/code-access?from=%2Facme%2Fweb%2Fpull%2F3%3Fx%3D1");
132134 assert.equal(codeGate("/acme/-/projects", "", none, "acme"), "/acme/-/code-access?from=%2Facme%2F-%2Fprojects");
133135 assert.equal(codeGate("/acme/-/chat/general", "", none, "acme"), null);
+18−17
6060
6161 /** Workspace pages that moved, by their old name under `-/`. */
6262 const MOVED: Record<string, string> = {
63− home: "-/today",
63+ // Home was called Today for a while.
64+ today: "-/home",
6465 "soon/teams": "-/teams",
6566 "soon/insights": "-/insights",
6667 };
122123 }
123124
124125 /**
125− * The dock's modes. Today is the front page;
126+ * The dock's modes. Home is the front page;
126127 * Chat, Agents, Code and Artifacts are where work happens; Notifications
127128 * spans them; People is who belongs; Workspace is the workspace itself:
128129 * its money, policies and settings; Apps is everything installed that you
132133 * sidebar of its own, or none, and which one is lit follows the address.
133134 */
134135 export type ModeKey =
135− | "today"
136+ | "home"
136137 | "chat"
137138 | "notifications"
138139 | "agents"
146147
147148 /** Workspace pages under `-/`, by the mode they belong to. Anything else of the workspace's is Code's. */
148149 const PAGE_MODES: Record<string, ModeKey> = {
149− today: "today",
150+ home: "home",
150151 chat: "chat",
151152 artifacts: "artifacts",
152153 agents: "agents",
188189 */
189190 export function modeOf(pathname: string, slug: string | null): ModeKey {
190191 const path = pagePath(pathname);
191− if (path === "/") return "today";
192+ if (path === "/") return "home";
192193 if (path === "/notifications" || path.startsWith("/notifications/")) return "notifications";
193194 if (path === "/settings" || path.startsWith("/settings/")) return "account";
194195 const parts = path.split("/").filter(Boolean);
195196 if (SITE_PAGES.has(parts[0] ?? "")) return "site";
196197 if (slug && parts[0]?.toLowerCase() === slug.toLowerCase()) {
197− if (parts.length === 1) return "today";
198+ if (parts.length === 1) return "home";
198199 if (parts[1] === "-") {
199200 // The workspace's security settings are a policy; its alerts are Code's.
200201 if (parts[2] === "security" && parts[3] === "settings") return "workspace";
207208 /** Where each mode's button in the dock goes, in the workspace `slug`. */
208209 export function modeHome(mode: ModeKey, slug: string): string {
209210 switch (mode) {
210− case "today":
211− return todayPath(slug);
211+ case "home":
212+ return homePagePath(slug);
212213 case "notifications":
213214 return "/notifications";
214215 case "chat":
232233 }
233234 }
234235
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`;
238239 }
239240
240241 /**
241242 * 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
243244 * for its panels (a tab, or `?agent=new` to put an agent on something).
244245 */
245246 export function homePath(slug: string, search = ""): string {
248249 params.delete("index");
249250 const query = params.toString();
250251 if (params.has("agent") || params.has("tab") || params.has("sort")) return `/${slug}/-/overview?${query}`;
251− return todayPath(slug);
252+ return homePagePath(slug);
252253 }
253254
254255 /** The page a member without Code access sees in place of anything of Code's. */
263264 * Where a member without Code access goes instead of `pathname`, or null
264265 * when the page is open to them. `noCode` is the workspaces (by slug) where
265266 * 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
267268 * Code pages and every repository page is the page that says to ask an
268269 * owner.
269270 */
272273 const path = pagePath(pathname);
273274 if (path === "/") {
274275 const slug = chosen && noCode.includes(chosen.toLowerCase()) ? chosen.toLowerCase() : null;
275− return slug ? todayPath(slug) : null;
276+ return slug ? homePagePath(slug) : null;
276277 }
277278 const parts = path.split("/").filter(Boolean);
278279 const slug = parts[0]?.toLowerCase();
279280 if (!slug || !noCode.includes(slug)) return null;
280281 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);
283284 if (parts[1] === "-") return CODE_PAGES.has(parts[2] ?? "") ? codeAccessPath(slug, from) : null;
284285 // A repository, and everything in it.
285286 return codeAccessPath(slug, from);
+6−4
211211 route(":folio", "routes/workspace/folios/folio.tsx"),
212212 route(":folio/history", "routes/workspace/folios/history.tsx"),
213213 ]),
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" }),
218220 route("-/apps", "routes/workspace/apps.tsx"),
219221 // The Marketplace: anyone browses it; owners add extensions and
220222 // integrations, and everyone else asks (`requests` takes every form it
+2−2
6565 Back to Chat
6666 </Link>
6767 <Link
68− to={`/${params.owner}/-/today`}
68+ to={`/${params.owner}/-/home`}
6969 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"
7070 >
71− Today
71+ Home
7272 </Link>
7373 </div>
7474 <p className="mt-6 text-xs text-faint">Agents can still explain how things work and what changed; they never show source code.</p>
+23−0
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+}
+234−85
11 import { ArrowRight } from "lucide-react";
2−import { Suspense, useState } from "react";
2+import { Suspense, useEffect, useState } from "react";
33 import { Await, Form, Link, data, redirect, useNavigation } from "react-router";
44
55 import { hasCodeAccess } from "@g1t/contracts";
66
77 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";
924 import { G1tMark, isOrchestrator } from "../../components/orchestrator";
1025 import { Skeleton } from "../../components/ui/skeleton";
1126 import { channelPath } from "../../lib/chat";
1227 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";
1356 import { readCookie } from "../../lib/mission";
1457 import { loadMissionControl } from "../../lib/mission-control.server";
1558 import { page } from "../../lib/meta";
1659 import { chat, inbox, workspaceAgents } from "../../lib/services.server";
1760 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";
2062
2163 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` });
2365 }
2466
25−/** Unread notifications read for Needs attention. */
67+/** Unread notifications read for Needs you. */
2668 const NOTIFICATIONS_READ = 50;
2769
2870 /**
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.
3480 */
3581 export async function loader({ params, context, request }: Route.LoaderArgs) {
3682 const viewer = requireUser(context, request);
4086 const code = hasCodeAccess(membership);
4187 const now = Date.now();
4288 const tz = readCookie(request.headers.get("cookie"), "g1t_tz");
89+ const windowKey = parseWindow(new URL(request.url).searchParams.get("window"));
4390
91+ // What needs you has no window: read at once, beside the last visit.
4492 const agentsP = workspaceAgents
4593 .list(slug, viewer)
4694 .then((result) => (result.ok ? result.value.filter((agent) => !agent.archived_at) : null))
4795 .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);
5699 const chatP = sidebarOrNull(slug, viewer);
57100 const notificationsP = inbox
58101 .list(viewer, { unread: true, limit: NOTIFICATIONS_READ })
59102 .then((result) => result.items)
60103 .catch(() => null);
104+ const lastSeenP = windowKey === "last" ? loadLastVisit(viewer, slug).catch(() => undefined) : Promise.resolve(null);
61105
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({
70112 slug,
71113 code,
72− codeNeeds,
73− capped: capped?.capped ?? null,
114+ codeNeeds: mission?.needs ?? null,
115+ capped,
74116 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,
76120 notifications,
77121 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);
81157
82158 const agents = await agentsP;
83159 return {
84160 slug,
85161 code,
86162 now,
87− date: shortDate(now, tz),
163+ tz,
164+ span,
88165 orchestrator: agents?.some(isOrchestrator) ?? false,
89− day,
166+ work,
167+ happened,
90168 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]),
93172 spend,
94173 };
95174 }
119198 /** Ask g1t, as one bar: Enter sends; Shift+Enter is a new line. Chat is one click away. */
120199 function AskG1t({ slug, available, error }: { slug: string; available: boolean; error: string | null }) {
121200 const navigation = useNavigation();
122− const here = `/${slug}/-/today`;
201+ const here = homePagePath(slug);
123202 const busy = navigation.state !== "idle" && navigation.formAction === here;
124203 const [text, setText] = useState("");
125204 return (
158237 );
159238 }
160239
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);
163278 return (
164279 <div className="mx-auto w-full max-w-[1040px] space-y-5 px-4 py-5 md:px-10 md:py-8">
165280 <header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
166281 <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>
168283 <p className="mt-1.5 max-w-2xl text-sm leading-relaxed text-muted">
169− <span className="text-fg-soft">{date}</span> ·{" "}
170284 <Suspense fallback={<Skeleton className="inline-block h-3.5 w-72 align-middle" />}>
171285 <Await resolve={head}>
172− {([work, att]) => (
286+ {([work, happened, att]) => (
173287 <>
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)}
175289 </>
176290 )}
177291 </Await>
178292 </Suspense>
179293 </p>
180294 </div>
181− <Suspense fallback={<ReviewButton count={null} />}>
182− <Await resolve={waiting}>{(att) => <ReviewButton count={att.rows.length} />}</Await>
183− </Suspense>
295+ <WindowSwitch span={span} />
184296 </header>
185297
186298 <AskG1t slug={slug} available={orchestrator} error={actionData?.error ?? null} />
187299
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>
198310 </Suspense>
199− <Suspense fallback={null}>
200− <Await resolve={day}>{(work) => <Sources work={work} />}</Await>
201− </Suspense>
202311 </Card>
203312 </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">
205314 <Suspense
206315 fallback={
207316 <Card title="Start here">
212321 <Await resolve={waiting}>{(att) => <StartHere attention={att} now={now} />}</Await>
213322 </Suspense>
214323 </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>
225335 </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>
227384 </div>
228385 );
229386 }
230387
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 "";
244393 }
+1−1
11 /**
2− * Workspace pages that moved: `-/home` is Today now, and `-/overview`
2+ * Workspace pages that moved: `-/today` is Home now, and `-/overview`
33 * the workspace's own page (lib/workspace-nav.ts).
44 */
55 import { data, redirect } from "react-router";
+2−2
3030 const viewer = getViewer(context);
3131 const slug = params.owner.toLowerCase();
3232 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
3434 // is the workspace as a visitor sees it.
35− if (role) throw redirect(`/${slug}/-/today`);
35+ if (role) throw redirect(`/${slug}/-/home`);
3636 const [listed, members, deploys, usage, published, shortcuts] = await Promise.all([
3737 // The listing the sidebar and the Projects tab share.
3838 workspaceProjects(slug, viewer),
+1−1
66
77 import type { Route } from "./+types/spend";
88 import { answer, agentsAction } from "../../components/agents/actions.server";
9−import { RowsSkeleton } from "../../components/today";
9+import { RowsSkeleton } from "../../components/home";
1010 import {
1111 BudgetLadder,
1212 ComingNote,
+14−0
259259 * Someone the room has never heard from is not listed: they are offline.
260260 */
261261 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 }>;
262274 };
263275
264276 async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
284296 presence: (user) => call("presence", { user_id: user.id, username: user.username }),
285297 setPresence: (user, change) => call("set_presence", { user_id: user.id, username: user.username, change }),
286298 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 }),
287301 };
288302 }
+30−1
1010 *
1111 * Kept in the object's own SQLite storage: the latest notifications, the
1212 * 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).
1415 *
1516 * Presence is worked out here from the tabs (src/presence.ts) and told to
1617 * the room of every workspace the person belongs to (src/room.ts), which
5455 type Kept,
5556 } from "./presence.ts";
5657 import type { Room } from "./room.ts";
58+import { type Visit, lastVisit, markVisit } from "./visits.ts";
5759 import { sendPush, type Vapid } from "./webpush.ts";
5860
5961 export type FeedEnv = {
9799 `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)`,
98100 );
99101 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)`);
100104 }
101105
102106 // ── Kept state ──────────────────────────────────────────────────────────
552556 if (person.username) this.setMeta("username", person.username.slice(0, 100));
553557 }
554558
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+
555584 async test(username: string): Promise<{ ok: boolean; pushed: number }> {
556585 const pushed = await this.tell(
557586 {
+10−1
1313 * - events: `notify`, for every inbox item, and `set_inbox`, the count after
1414 * items arrive or are marked, naming the person by username;
1515 * - 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);
1719 * - agents and the site: `workspace_presence`, how a workspace's people
1820 * show now (away, in Do Not Disturb, offline, and their statuses);
1921 * - integrations, later: `set_presence` with a status of `source`
3335
3436 import { FEED_USERNAME_HEADER, FEED_USER_ID_HEADER, Feed, type FeedEnv } from "./feed.ts";
3537 import { onlyPeople } from "./presence.ts";
38+import { visitWorkspace } from "./visits.ts";
3639
3740 export { Feed } from "./feed.ts";
3841 export { Room } from "./room.ts";
97100 return Response.json(await stub.status(typeof args.endpoint === "string" ? args.endpoint : null));
98101 case "set_preferences":
99102 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+ }
100109 case "test":
101110 return Response.json(await stub.test(typeof args.username === "string" ? args.username : ""));
102111 case "presence":
+52−0
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+});
+56−0
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+}