Merge branch 'rm-orgchart-getstarted'
40 files+121−13450/40 viewed
| 37 | 37 | | Chat: channels, direct messages and threads, live, with people and agents as members | Live | | |
| 38 | 38 | | Notifications: mentions, reviews, approvals and alerts, with browser push | Live | | |
| 39 | 39 | | People and teams: members, roles, nested teams, invites | Live | | |
| 40 | − | | A directory of people and agents, profiles, org chart, teams with agent members | Coming | | |
| 40 | + | | A directory of people and agents, profiles, teams with agent members | Coming | | |
| 41 | 41 | | Artifacts: documents written together, live, by people and agents | Live | | |
| 42 | 42 | | Slides, designs and dashboards as artifacts | Coming | | |
| 43 | 43 | | Scratchpads: tools, reports and agents anyone can build on a copy of the data, promoted after review | Coming | |
| 23 | 23 | ||
| 24 | 24 | A workspace can have as many agents as it likes, and an agent costs nothing | |
| 25 | 25 | while nobody talks to it. Hire one from each template, add each to its | |
| 26 | − | team, and a workspace's org chart can read like a real company's: | |
| 26 | + | team, and a workspace's teams can read like a real company's: | |
| 27 | 27 | ||
| 28 | 28 | | Team | Hired from the template | As | | |
| 29 | 29 | | --- | --- | --- | | |
| ⋯ | |||
| 312 | 312 | An agent's page in Agents is its only profile. The workspace's | |
| 313 | 313 | [People directory](/guides/people-and-teams/#the-directory) lists people | |
| 314 | 314 | only; agents appear there on the teams they are on, under the team's | |
| 315 | − | people and marked **Agent**, and in the org chart beside the person who | |
| 316 | − | leads their team. | |
| 315 | + | people and marked **Agent**. | |
| 317 | 316 | ||
| 318 | 317 | The Agents sidebar groups specialists by the teams they are on, each team | |
| 319 | 318 | by name: an agent on two teams shows under both, and agents on none show | |
| ⋯ | |||
| 505 | 504 | Every agent, not only g1t, knows the team: each colleague's name, title, | |
| 506 | 505 | the teams it is on, responsibilities and status. It also knows the people and agents on | |
| 507 | 506 | its own [teams](/guides/teams/): who leads each, what each person owns, | |
| 508 | − | who reports to whom, who is around now and their local time, and who to | |
| 507 | + | who is around now and their local time, and who to | |
| 509 | 508 | ask when a person is needed. It is told this every turn; see | |
| 510 | 509 | [what agents are told](/guides/people-and-teams/#what-agents-are-told). | |
| 511 | 510 | When a question belongs to someone else, it makes one of three moves, | |
| 334 | 334 | too, and any project can show an uploaded image instead. See | |
| 335 | 335 | [production's picture](/guides/projects/#productions-picture). | |
| 336 | 336 | ||
| 337 | − | The [checklist on the overview](/guides/projects/#get-to-production) also | |
| 338 | − | tracks the first production deploy, a custom domain and a first preview. | |
| 339 | − | ||
| 340 | 337 | ## Deploying from a workflow | |
| 341 | 338 | ||
| 342 | 339 | A [workflow](/guides/actions/) job can build and deploy the project |
| 1 | 1 | --- | |
| 2 | 2 | title: People and teams | |
| 3 | − | description: Find anyone in a workspace, see what they own and who they report to, put agents on teams beside them, and know what agents are told about their teams. | |
| 3 | + | description: Find anyone in a workspace, see what they own, put agents on teams beside them, and know what agents are told about their teams. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | **People** is where a workspace's people are listed: who they are, the | |
| 7 | − | teams they are on, what they own and who they report to. Agents are not | |
| 8 | − | in it: each has its page under [Agents](/guides/agents/), and agents join | |
| 7 | + | teams they are on and what they own. Agents are not in it: each has its page under [Agents](/guides/agents/), and agents join | |
| 9 | 8 | [teams](/guides/teams/) beside people. Open it from **People** at the foot | |
| 10 | 9 | of the rail. Its sidebar has: | |
| 11 | 10 | ||
| ⋯ | |||
| 13 | 12 | | --- | --- | --- | | |
| 14 | 13 | | **Everyone** | `g1t.sh/<workspace>/-/people` | The [directory](#the-directory) of the workspace's people. | | |
| 15 | 14 | | **Teams** | `g1t.sh/<workspace>/-/teams` | The workspace's [teams](/guides/teams/). | | |
| 16 | − | | **Org chart** | `g1t.sh/<workspace>/-/org-chart` | Who reports to whom. See [the org chart](#the-org-chart). | | |
| 17 | 15 | ||
| 18 | 16 | Only members of the workspace can open these pages. Who belongs, and | |
| 19 | 17 | inviting people, is the workspace's own business: **Members**, | |
| ⋯ | |||
| 46 | 44 | [Agents](/guides/agents/), and a team's page lists the agents on it under | |
| 47 | 45 | its people (see [agents on a team](/guides/teams/#agents-on-a-team)). | |
| 48 | 46 | ||
| 49 | − | **Org chart** opens [the org chart](#the-org-chart). Owners also see | |
| 50 | − | **Invite people**, which opens the workspace's | |
| 47 | + | Owners see **Invite people**, which opens the workspace's | |
| 51 | 48 | [Members](#members-invitations-and-permissions) page, where **Invite** is. | |
| 52 | 49 | ||
| 53 | 50 | ## A person's profile | |
| ⋯ | |||
| 63 | 60 | | Card | What it shows | | |
| 64 | 61 | | --- | --- | | |
| 65 | 62 | | **Teams** | The teams they are on, with **Lead** or **Maintainer** beside each where it applies. | | |
| 66 | − | | **Reporting line** | Who they report to, and who reports to them. | | |
| 67 | 63 | | **Agents on their teams** | Every agent on the teams they are on. | | |
| 68 | 64 | | **Access** | **Code**: their role on the workspace's repositories, such as *Admin on every repository, as an owner* or *Read on every repository, the workspace's base permission*, then any roles through teams, such as *Roles on 3 repositories through Backend*. **Storage**: coming. **Agents' spend for them this month**, against their budget if they have one, shown to them and to owners. | | |
| 69 | 65 | ||
| ⋯ | |||
| 81 | 77 | | --- | --- | --- | | |
| 82 | 78 | | **Title** | The person, or an owner | Their job here, such as *Staff Engineer*. Up to 80 characters. | | |
| 83 | 79 | | **What they own** | The person, or an owner | One per line: a product, a process, an account. Up to 8, each up to 60 characters. Repeats are left out. | | |
| 84 | − | | **Reports to** | Owners only | Another member of the workspace, or no one. Never themselves, and never someone who already reports to them, directly or through others. | | |
| 85 | 80 | ||
| 86 | 81 | What you set here is shown in the directory and told to the agents on | |
| 87 | 82 | their teams (see [what agents are told](#what-agents-are-told)). Each | |
| 88 | 83 | change is recorded in the workspace's [audit log](/guides/audit-log/) as | |
| 89 | 84 | `member.profile_edited`. | |
| 90 | 85 | ||
| 91 | − | When someone leaves the workspace, anyone who reported to them reports to | |
| 92 | − | no one, and any team they led has no lead. | |
| 86 | + | When someone leaves the workspace, any team they led has no lead. | |
| 93 | 87 | ||
| 94 | 88 | ## Agents | |
| 95 | 89 | ||
| ⋯ | |||
| 99 | 93 | memory, routines, spend and activity, and its **Profile** tab, which has | |
| 100 | 94 | its definition and, under **Teams**, the teams it is on and | |
| 101 | 95 | [what it is told about them](#what-agents-are-told). Wherever People | |
| 102 | − | names an agent, on a team's page, on a person's profile under **Agents | |
| 103 | − | on their teams** or in the org chart, the name opens that page. | |
| 96 | + | names an agent, on a team's page or on a person's profile under | |
| 97 | + | **Agents on their teams**, the name opens that page. | |
| 104 | 98 | ||
| 105 | 99 | Agents still join teams like anyone: see [teams of any mix](#teams-of-any-mix) | |
| 106 | 100 | below. The old address of an agent's People profile, | |
| ⋯ | |||
| 120 | 114 | | Add or remove an agent | The team's **People and agents** tab, or **Teams** on the agent's **Profile** tab in Agents. See [agents on a team](/guides/teams/#agents-on-a-team). | | |
| 121 | 115 | | Set the lead, channel and budget | The team's **Settings**. See [lead, channel and budget](/guides/teams/#lead-channel-and-budget). | | |
| 122 | 116 | | Storage for a team | Coming. | | |
| 123 | − | ||
| 124 | − | ## The org chart | |
| 125 | 117 | ||
| 126 | − | The org chart, `g1t.sh/<workspace>/-/org-chart`, draws the reporting | |
| 127 | − | lines as a tree: each person, with the people who report to them below. | |
| 128 | − | Beside each person are the agents on the teams they lead, as members of | |
| 129 | − | those teams: hover one for its name, title and that it is an agent, and | |
| 130 | − | choose it to open its page in Agents. Agents have no place of their own | |
| 131 | − | in the tree, since no one reports to an agent and an agent reports to no | |
| 132 | − | one. | |
| 133 | − | ||
| 134 | − | Under **Teams no person leads** are the teams led by an agent or by no | |
| 135 | − | one, each with its agents. | |
| 136 | − | ||
| 137 | − | Until someone has a manager, the page says so. Owners set who each person | |
| 138 | − | reports to with **Edit profile** on that person's profile (see | |
| 139 | − | [edit a profile](#edit-a-profile)). | |
| 140 | − | ||
| 141 | 118 | ## What agents are told | |
| 142 | 119 | ||
| 143 | 120 | Every time an agent replies, and at every step of a | |
| ⋯ | |||
| 150 | 127 | 1. The team's description, who leads it, its channel and its shared | |
| 151 | 128 | budget for agents. | |
| 152 | 129 | 2. Each person on it: name and username, title, whether they are the lead | |
| 153 | − | or a maintainer, what they own, who they report to, how they show right | |
| 154 | − | now, and their local time. | |
| 130 | + | or a maintainer, what they own, how they show right now, and their | |
| 131 | + | local time. | |
| 155 | 132 | 3. Each agent on it, with *you* for itself. | |
| 156 | 133 | 4. **When a person is needed**: who to ask. | |
| 157 | 134 | ||
| ⋯ | |||
| 202 | 179 | ||
| 203 | 180 | Payments, invoices and the billing pages. Led by Priya Shah (@priya). Its channel is #billing. Its agents share a budget of $150.00 a month. | |
| 204 | 181 | ||
| 205 | − | - Priya Shah (@priya); Engineering Manager, the lead, a maintainer; owns Stripe, Invoices; reports to @dana (online, 14:05 their time) | |
| 206 | − | - Leo Park (@leo); Staff Engineer; owns Refunds; reports to @priya (focusing, in Do Not Disturb until 16:00 their time, 22:05 their time) | |
| 182 | + | - Priya Shah (@priya); Engineering Manager, the lead, a maintainer; owns Stripe, Invoices (online, 14:05 their time) | |
| 183 | + | - Leo Park (@leo); Staff Engineer; owns Refunds (focusing, in Do Not Disturb until 16:00 their time, 22:05 their time) | |
| 207 | 184 | - @margo: you | |
| 208 | 185 | - @sam (Sam), an agent: Support Specialist | |
| 209 | 186 | ||
| 35 | 35 | ||
| 36 | 36 | | Page | Address | | | |
| 37 | 37 | | --- | --- | --- | | |
| 38 | − | | **Overview** | `g1t.sh/<workspace>/<project>` | What it is and its links; production wherever it is deployed, or for a library its packages; the steps that apply to it; what is in progress, active branches, the latest commits, and its About. See [the overview](#the-overview). | | |
| 38 | + | | **Overview** | `g1t.sh/<workspace>/<project>` | What it is and its links; production wherever it is deployed, or for a library its packages; what is in progress, active branches, the latest commits, and its About. See [the overview](#the-overview). | | |
| 39 | 39 | | **Code** | `…/code` | The repository's files, commits and branches. | | |
| 40 | 40 | | **Issues**, **Pull requests**, **Merge queue**, **Plan** | `…/issues` and so on | As they always were. | | |
| 41 | 41 | | **Actions** | `…/actions` | [GitHub Actions workflows](/guides/actions/). | | |
| ⋯ | |||
| 57 | 57 | | **Production** | [Production's picture](#productions-picture), which opens the live site, beside the rest. For an app [deployed on g1t](/guides/deployments/): its address, the commit it runs and when it went up; **Visit** and **Redeploy**. For an app deployed elsewhere: production's address and **Visit**, with its latest reported production deployment or the default branch's latest commit. For an app nobody has placed yet: one question, where it runs. | | |
| 58 | 58 | | **Packages** | For a library or a tool, in place of Production: the packages its repository publishes, each with its latest version and how to install it, or how to publish a first one. | | |
| 59 | 59 | | **Documentation**, **Homepage** | For docs, where they are read; for anything else, its homepage. | | |
| 60 | − | | **Get to production**, **Ship a release** or **Get started** | The [checklist](#the-checklist) for what the project is, until every step is done or you dismiss it. | | |
| 61 | 60 | | **Right now** | Agents at work, and open pull requests moving from working to landed. | | |
| 62 | 61 | | **Needs you** | What is waiting on a person: a failed production build, a pull request to merge or review, a stuck run. | | |
| 63 | 62 | | **Active branches** | Branches other than the default, newest first. See [active branches](#active-branches). | | |
| ⋯ | |||
| 123 | 122 | Anyone who can change the project's settings (the **Maintain** role on | |
| 124 | 123 | its repository and up, as for production's address) can upload, replace | |
| 125 | 124 | or remove it. | |
| 126 | − | ||
| 127 | − | ### The checklist | |
| 128 | − | ||
| 129 | − | People with a role on its repository see a card that counts what the | |
| 130 | − | project has done, such as **3/5**. Its steps follow | |
| 131 | − | [what the project is](#what-a-project-is): only an app deployed on g1t is | |
| 132 | − | asked to deploy, add a domain or open a preview. Each step is worked out | |
| 133 | − | from the project itself, and each links to where you do it. The card goes | |
| 134 | − | away when every step is done. To hide it sooner, choose **×** on it. That | |
| 135 | − | hides it for this project in this browser only. | |
| 136 | − | ||
| 137 | − | | What it is | Card | Steps | | |
| 138 | − | | --- | --- | --- | | |
| 139 | − | | App or site, deployed on g1t | **Get to production** | [Below](#get-to-production). | | |
| 140 | − | | Library, tool | **Ship a release** | [Below](#ship-a-release). | | |
| 141 | − | | App, deployed elsewhere | **Get started** | Push code; add production's address; add checks on pull requests; repository instructions; a first issue for g1t. | | |
| 142 | − | | App nobody has placed yet | **Get started** | Push code; say where it runs; add checks on pull requests; repository instructions; a first issue for g1t. | | |
| 143 | − | | Documentation | **Get started** | Push code; add where its docs are read (a docs or homepage address); repository instructions; a first issue for g1t. | | |
| 144 | − | | Something else | **Get started** | Push code; add its links; repository instructions; a first issue for g1t. | | |
| 145 | − | ||
| 146 | − | ### Get to production | |
| 147 | − | ||
| 148 | − | For an app or site deployed on g1t: | |
| 149 | 125 | ||
| 150 | − | | Step | Done when | Links to | | |
| 151 | − | | --- | --- | --- | | |
| 152 | − | | Connect a source or push code | The default branch has a commit, or the source is a mirror. | **Code**, which shows how to push, or how to have your coding agent start the project. | | |
| 153 | − | | Deploy to production | A production build has gone live. | Deployments settings, or **Deployments** once they are on. | | |
| 154 | − | | Add a custom domain | The project has a [custom domain](/guides/deployments/#custom-domains). | Domain settings. | | |
| 155 | − | | Open a preview | A branch or pull request has had a [preview](/guides/deployments/#previews-of-branches). | A new pull request. | | |
| 156 | − | | Set up repository instructions | `AGENTS.md` or `CLAUDE.md` is at the root of the default branch. | The instructions on the **Agents** page. See [repository instructions](/guides/working-with-g1t/#repository-instructions). | | |
| 157 | − | | Assign a first issue to g1t | g1t has had a run, a pull request or an issue here. | A new issue. | | |
| 158 | − | ||
| 159 | − | ### Ship a release | |
| 160 | − | ||
| 161 | − | A library or a tool does not deploy, so its card counts the steps to a | |
| 162 | − | first release instead: | |
| 163 | − | ||
| 164 | − | | Step | Done when | Links to | | |
| 165 | − | | --- | --- | --- | | |
| 166 | − | | Connect a source or push code | As above. | **Code**. | | |
| 167 | − | | Add checks on pull requests | The repository has a workflow in `.g1t/workflows`. Its runs are every pull request's checks. | **Actions**, which offers to add a starter workflow. | | |
| 168 | − | | Tag a release or publish a package | A package its repository publishes has a version. For [Composer](/guides/composer/) and [Go](/guides/go/), pushing a tag such as `v1.0.0` is the release. | The package's page, or the guide for its registry. | | |
| 169 | − | | Set up repository instructions | As above. | The **Agents** page. | | |
| 170 | − | | Assign a first issue to g1t | As above. | A new issue. | | |
| 171 | − | ||
| 172 | 126 | ## What a project is | |
| 173 | 127 | ||
| 174 | 128 | A project is one of these, and its overview follows: | |
| 175 | 129 | ||
| 176 | 130 | | What it is | Its overview shows | | |
| 177 | 131 | | --- | --- | | |
| 178 | − | | **App or site, deployed on g1t** | Production on g1t.page, with previews and domains, and **Get to production**. | | |
| 132 | + | | **App or site, deployed on g1t** | Production on g1t.page, with previews and domains. | | |
| 179 | 133 | | **App or site, deployed elsewhere** | Production at the address you give, deployed by your own pipeline, with **Visit**. It is never asked to turn on Deployments; Deployment settings stay one link away. | | |
| 180 | − | | **Library or package** | The packages its repository publishes and how to install them, and **Ship a release**. | | |
| 134 | + | | **Library or package** | The packages its repository publishes and how to install them. | | |
| 181 | 135 | | **Tool or CLI** | The same as a library: its packages and releases. | | |
| 182 | 136 | | **Documentation** | Where its docs are read. Docs can be published on g1t.page too, by turning on Deployments. | | |
| 183 | 137 | | **Something else** | Its homepage and links. | | |
| 15 | 15 | Only members of a workspace, and its own agents, can be in its teams. | |
| 16 | 16 | Someone who leaves the workspace leaves all of its teams with it. | |
| 17 | 17 | ||
| 18 | − | For the workspace's directory, profiles and org chart, see | |
| 18 | + | For the workspace's directory and profiles, see | |
| 19 | 19 | [people and teams](/guides/people-and-teams/). | |
| 20 | 20 | ||
| 21 | 21 | ## Create a team | |
| ⋯ | |||
| 182 | 182 | ||
| 183 | 183 | Every agent on a visible team is told about it every time it replies and | |
| 184 | 184 | at every session step: its description, lead, channel and budget, each | |
| 185 | − | person with their title, what they own, who they report to, whether they | |
| 186 | − | are around and their local time, and the team's other agents. The | |
| 185 | + | person with their title, what they own, whether they are around and | |
| 186 | + | their local time, and the team's other agents. The | |
| 187 | 187 | **People and agents** tab shows the team's part of it, exactly as the | |
| 188 | 188 | agents get it, under **What the agents on** *team* **know about it**. | |
| 189 | 189 | ||
| 571 | 571 | | [**Packages**](/guides/packages/) | `/-/packages` | Everyone | What the workspace publishes. A visitor opens it from **Packages** on the workspace's page. | | |
| 572 | 572 | | [**Teams**](/guides/teams/) | `/-/teams` | Members | Groups of members given roles on repositories together, mentioned as `@workspace/team` and asked to review together. Each team has its own page at `/-/teams/<team>`. | | |
| 573 | 573 | | [**People**](/guides/people-and-teams/) | `/-/people` | Members | The workspace's people, with their titles, teams and what they own. Each has a profile at `/-/people/<username>`. Agents are not in it; an agent's page is `/-/agents/<handle>`. | | |
| 574 | − | | [**Org chart**](/guides/people-and-teams/#the-org-chart) | `/-/org-chart` | Members | Who reports to whom, with the agents on the teams each person leads beside them. | | |
| 575 | 574 | | **Members and invites** | `/-/members` | Members | Who belongs. Owners invite, change roles and remove people here. | | |
| 576 | 575 | | **Insights** | `/-/insights` | Members | Coming soon: how the whole workspace delivers. | | |
| 577 | 576 | | **Settings** | `/-/settings` | Owners | How the workspace is set up and connected (below). | | |
| ⋯ | |||
| 637 | 636 | | **Code** | Projects, pull requests and checks. Not shown to a member without [Code access](/guides/agent-access/). | | |
| 638 | 637 | | **Agents** | The workspace's agents and their sessions, and how they are set up. See [agents](/guides/agents/). | | |
| 639 | 638 | | **Artifacts** | Documents, decks and pages. See [artifacts](/guides/artifacts/). | | |
| 640 | − | | **People** | Everyone in the workspace (its people; agents are under Agents); its [teams](/guides/teams/), which agents can be on; and the org chart. See [people and teams](/guides/people-and-teams/). | | |
| 639 | + | | **People** | Everyone in the workspace (its people; agents are under Agents); and its [teams](/guides/teams/), which agents can be on. See [people and teams](/guides/people-and-teams/). | | |
| 641 | 640 | | **Workspace** | The workspace's settings: who has access, money, compute, and how each part of g1t is set up. See [Workspace mode](#workspace-mode). | | |
| 642 | 641 | ||
| 643 | 642 | The app you are in sits on a filled square. Your account is the | |
| 66 | 66 | | [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" /> | | |
| 67 | 67 | | [Chat](/guides/chat/) | Channels, direct messages and threads, live. People and agents are members alike. No seats and no history limit. | <Status is="live" /> | | |
| 68 | 68 | | [Notifications](/guides/notifications/) | Everything waiting on you: mentions, reviews, approvals and alerts, with browser push. | <Status is="live" /> | | |
| 69 | − | | [People and teams](/guides/people-and-teams/) | Everyone in the workspace in one search, with profiles, reporting lines, an org chart and teams that include agents beside people. Members, roles, nested teams and invites. | <Status is="live" /> | | |
| 69 | + | | [People and teams](/guides/people-and-teams/) | Everyone in the workspace in one search, with profiles and teams that include agents beside people. Members, roles, nested teams and invites. | <Status is="live" /> | | |
| 70 | 70 | | [Artifacts](/guides/artifacts/) | Documents written together, live, by people and agents. Private until you share them. | <Status is="live" /> | | |
| 71 | 71 | | More kinds of artifact | Slides, designs and dashboards that keep the query behind every number. | <Status is="coming" /> | | |
| 72 | 72 | | Scratchpads | Tools, reports and agents anyone can build on a copy of the data, used in production only after review. | <Status is="coming" /> | | |
| ⋯ | |||
| 208 | 208 | /> | |
| 209 | 209 | <LinkCard | |
| 210 | 210 | title="People and teams" | |
| 211 | − | description="Find anyone, person or agent, see what they own and who they report to, and read what agents are told about their teams." | |
| 211 | + | description="Find anyone, person or agent, see what they own, and read what agents are told about their teams." | |
| 212 | 212 | href="/guides/people-and-teams/" | |
| 213 | 213 | /> | |
| 214 | 214 | <LinkCard | |
| 1 | − | import { Check, ChevronRight, X } from "lucide-react"; | |
| 2 | − | import { useCallback, useSyncExternalStore } from "react"; | |
| 3 | − | import { Link } from "react-router"; | |
| 4 | − | ||
| 5 | − | import { type ChecklistItem, dismiss, isDismissed, progress } from "../lib/checklist"; | |
| 6 | − | import { cn } from "../lib/cn"; | |
| 7 | − | import { Button } from "./ui/button"; | |
| 8 | − | import { Hint } from "./ui/hint"; | |
| 9 | − | ||
| 10 | − | const storage = () => (typeof window === "undefined" ? null : window.localStorage); | |
| 11 | − | const listeners = new Set<() => void>(); | |
| 12 | − | const subscribe = (listener: () => void) => { | |
| 13 | − | listeners.add(listener); | |
| 14 | − | return () => listeners.delete(listener); | |
| 15 | − | }; | |
| 16 | − | ||
| 17 | − | /** | |
| 18 | − | * The steps to production, or to a library's first release, for one | |
| 19 | − | * project, as `3/6`, until they are all done or someone dismisses it. | |
| 20 | − | * Dismissing is per project and remembered by this browser. | |
| 21 | − | */ | |
| 22 | − | export function ProductionChecklist({ | |
| 23 | − | base, | |
| 24 | − | items, | |
| 25 | − | title = "Get to production", | |
| 26 | − | }: { | |
| 27 | − | base: string; | |
| 28 | − | items: ChecklistItem[]; | |
| 29 | − | /** "Ship a release" for a library. */ | |
| 30 | − | title?: string; | |
| 31 | − | }) { | |
| 32 | − | const hidden = useSyncExternalStore( | |
| 33 | − | subscribe, | |
| 34 | − | useCallback(() => isDismissed(storage, base), [base]), | |
| 35 | − | () => false, | |
| 36 | − | ); | |
| 37 | − | const { done, total, complete } = progress(items); | |
| 38 | − | if (hidden || complete) return null; | |
| 39 | − | const next = items.find((item) => !item.done)?.key; | |
| 40 | − | ||
| 41 | − | return ( | |
| 42 | − | <section aria-labelledby="checklist-title" className="rounded-2xl border border-line bg-surface"> | |
| 43 | − | <div className="flex items-center gap-3 px-5 pt-4 pb-3 sm:px-6"> | |
| 44 | − | <h2 id="checklist-title" className="text-sm font-semibold"> | |
| 45 | − | {title} | |
| 46 | − | </h2> | |
| 47 | − | <span className="rounded-full bg-raised px-2 py-px text-xs font-medium tabular-nums text-muted"> | |
| 48 | − | {done}/{total} | |
| 49 | − | </span> | |
| 50 | − | <span className="hidden h-1.5 w-24 overflow-hidden rounded-full bg-line sm:block" aria-hidden> | |
| 51 | − | <span className="block h-full rounded-full bg-success" style={{ width: `${(done / total) * 100}%` }} /> | |
| 52 | − | </span> | |
| 53 | − | <Hint label="Dismiss for this project"> | |
| 54 | − | <Button | |
| 55 | − | type="button" | |
| 56 | − | onClick={() => { | |
| 57 | − | dismiss(storage, base); | |
| 58 | − | for (const listener of listeners) listener(); | |
| 59 | − | }} variant="ghost" size="inline" className="ml-auto p-1 text-faint" aria-label="Dismiss the checklist for this project"> | |
| 60 | − | <X size={14} /> | |
| 61 | − | </Button> | |
| 62 | − | </Hint> | |
| 63 | − | </div> | |
| 64 | − | <ol className="grid border-t border-line sm:grid-cols-2 lg:grid-cols-3"> | |
| 65 | − | {items.map((item) => ( | |
| 66 | − | <li key={item.key} className="border-b border-line last:border-b-0 sm:[&:nth-last-child(-n+2)]:border-b-0 lg:[&:nth-last-child(-n+3)]:border-b-0"> | |
| 67 | − | <Link | |
| 68 | − | to={item.to} | |
| 69 | − | className={cn( | |
| 70 | − | "group flex h-full items-start gap-3 px-5 py-3 transition-colors hover:bg-raised/50 sm:px-6", | |
| 71 | − | item.done && "opacity-70", | |
| 72 | − | )} | |
| 73 | − | > | |
| 74 | − | <span | |
| 75 | − | className={cn( | |
| 76 | − | "mt-px flex size-4.5 shrink-0 items-center justify-center rounded-full border", | |
| 77 | − | item.done ? "border-success bg-success text-bg" : item.key === next ? "border-success" : "border-line-strong", | |
| 78 | − | )} | |
| 79 | − | aria-hidden | |
| 80 | − | > | |
| 81 | − | {item.done && <Check size={11} strokeWidth={3} />} | |
| 82 | − | </span> | |
| 83 | − | <span className="min-w-0 grow"> | |
| 84 | − | <span className={cn("block text-[0.8125rem] font-medium", item.done && "text-muted line-through decoration-line-strong")}> | |
| 85 | − | {item.title} | |
| 86 | − | <span className="sr-only">{item.done ? " (done)" : ""}</span> | |
| 87 | − | </span> | |
| 88 | − | {!item.done && <span className="mt-0.5 block text-xs leading-5 text-muted">{item.detail}</span>} | |
| 89 | − | </span> | |
| 90 | − | {!item.done && ( | |
| 91 | − | <ChevronRight size={14} className="mt-0.5 shrink-0 text-faint transition-colors group-hover:text-fg" /> | |
| 92 | − | )} | |
| 93 | − | </Link> | |
| 94 | − | </li> | |
| 95 | − | ))} | |
| 96 | − | </ol> | |
| 97 | − | </section> | |
| 98 | − | ); | |
| 99 | − | } |
| 2 | 2 | import { type ReactNode, useEffect, useState } from "react"; | |
| 3 | 3 | import { Link, useFetcher, useLocation, useRouteLoaderData, useSearchParams } from "react-router"; | |
| 4 | 4 | ||
| 5 | − | import { trialClosed } from "../lib/trial"; | |
| 6 | 5 | import { type ActivityGroup, type Verb, greetingFor, isAgent } from "../lib/mission"; | |
| 7 | 6 | import { | |
| 8 | 7 | BLOCKING, | |
| ⋯ | |||
| 28 | 27 | import { cn } from "../lib/cn"; | |
| 29 | 28 | import { microsOf, money } from "../lib/money"; | |
| 30 | 29 | import { AgentComposer, type ComposerResult } from "./agent-composer"; | |
| 31 | − | import { AgentSetup } from "./agent-setup"; | |
| 32 | 30 | import { InboxNeedsCard } from "./inbox"; | |
| 33 | 31 | import type { ShellData } from "./shell"; | |
| 34 | 32 | import { useLiveRefresh } from "./agents"; | |
| ⋯ | |||
| 621 | 619 | </li> | |
| 622 | 620 | ); | |
| 623 | 621 | } | |
| 624 | − | ||
| 625 | − | // --- Get started -------------------------------------------------------------------- | |
| 626 | 622 | ||
| 627 | − | type Step = { done: boolean; title: string; about: string; to: string | null; action: string }; | |
| 628 | − | ||
| 629 | − | /** The first things to do, ticked off as they are done, until work has been handed to agents. */ | |
| 630 | − | function GetStarted({ steps }: { steps: Step[] }) { | |
| 631 | − | const left = steps.filter((step) => !step.done).length; | |
| 632 | − | return ( | |
| 633 | − | <Card asChild className="p-5"> | |
| 634 | − | <section> | |
| 635 | − | <div className="flex items-baseline justify-between gap-3"> | |
| 636 | − | <h2 className="text-base font-semibold tracking-tight">Get started</h2> | |
| 637 | − | <span className="text-xs text-muted"> | |
| 638 | − | {steps.length - left} of {steps.length} done | |
| 639 | − | </span> | |
| 640 | − | </div> | |
| 641 | − | <ol className="mt-4 space-y-2"> | |
| 642 | − | {steps.map((step, index) => ( | |
| 643 | − | <li | |
| 644 | − | key={step.title} | |
| 645 | − | className={cn("flex items-start gap-3 rounded-lg px-3 py-2.5", step.done ? "" : "bg-bg/50 ring-1 ring-line")} | |
| 646 | − | > | |
| 647 | − | <span | |
| 648 | − | className={cn( | |
| 649 | − | "mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full text-[0.6875rem] font-medium", | |
| 650 | − | step.done ? "bg-success text-bg" : "text-muted ring-1 ring-line-strong", | |
| 651 | − | )} | |
| 652 | − | > | |
| 653 | − | {step.done ? <Check size={12} /> : index + 1} | |
| 654 | − | </span> | |
| 655 | − | <span className="min-w-0 grow"> | |
| 656 | − | <span className={cn("block text-sm font-medium", step.done && "text-muted line-through decoration-faint")}>{step.title}</span> | |
| 657 | − | {!step.done && <span className="mt-0.5 block text-xs leading-5 text-muted">{step.about}</span>} | |
| 658 | − | </span> | |
| 659 | − | {!step.done && step.to && ( | |
| 660 | − | <Link | |
| 661 | − | to={step.to} | |
| 662 | − | className="shrink-0 rounded-md bg-fg px-2.5 py-1 text-xs font-medium text-bg transition-colors hover:bg-fg-hover" | |
| 663 | − | > | |
| 664 | − | {step.action} | |
| 665 | − | </Link> | |
| 666 | − | )} | |
| 667 | − | </li> | |
| 668 | − | ))} | |
| 669 | − | </ol> | |
| 670 | − | </section> | |
| 671 | − | </Card> | |
| 672 | − | ); | |
| 673 | − | } | |
| 674 | − | ||
| 675 | 623 | // --- The page ----------------------------------------------------------------------- | |
| 676 | 624 | ||
| 677 | 625 | const TAB_LABEL: Record<Tab, string> = { needs: "Needs you", waiting: "Waiting on agents", landed: "Landed today" }; | |
| ⋯ | |||
| 709 | 657 | } | |
| 710 | 658 | }, []); | |
| 711 | 659 | ||
| 712 | − | const { viewer, repos, waiting, landed, liveTotal, week, groups, titles, stats, canRunAgents, trial } = loaded; | |
| 660 | + | const { viewer, repos, waiting, landed, liveTotal, week, groups, titles, stats, canRunAgents } = loaded; | |
| 713 | 661 | const workspace = shell?.workspace?.slug ?? loaded.workspace ?? viewer.workspaces?.[0]?.slug ?? null; | |
| 714 | 662 | ||
| 715 | 663 | // The usage limit, as the sidebar knows it, heads the list when it bites. | |
| ⋯ | |||
| 761 | 709 | return text ? `?${text}` : pathname; | |
| 762 | 710 | }; | |
| 763 | 711 | ||
| 764 | − | const steps: Step[] = [ | |
| 765 | − | { | |
| 766 | − | done: Boolean(workspace), | |
| 767 | − | title: "Create a workspace", | |
| 768 | − | about: "Projects, people and agent credit live in one.", | |
| 769 | − | to: "/workspaces/new", | |
| 770 | − | action: "Create", | |
| 771 | − | }, | |
| 772 | − | { | |
| 773 | − | done: repos.length > 0, | |
| 774 | − | title: "Add a project", | |
| 775 | − | about: "Create a repository, or import one by its address. Push to it with git as usual.", | |
| 776 | − | to: "/new", | |
| 777 | − | action: "Add", | |
| 778 | − | }, | |
| 779 | − | trial?.open | |
| 780 | − | ? { | |
| 781 | − | done: true, | |
| 782 | − | title: "Try g1t free", | |
| 783 | − | about: trial.granted | |
| 784 | − | ? `This workspace has ${dollars(trial.limitMicros - trial.usedMicros)} of its ${dollars(trial.limitMicros)} trial credit left, for g1t's models and sandboxes.` | |
| 785 | − | : `This workspace gets ${dollars(trial.limitMicros)} of trial credit, for g1t's models and sandboxes, the first time g1t works here.`, | |
| 786 | − | to: workspace ? `/${workspace}/-/integrations/models` : null, | |
| 787 | − | action: "Connect", | |
| 788 | − | } | |
| 789 | − | : canRunAgents | |
| 790 | − | ? { | |
| 791 | − | done: Boolean(shell?.free) || shell?.limit == null || shell.limit.comped || shell.limit.state === "ok", | |
| 792 | − | title: "Keep work running", | |
| 793 | − | about: | |
| 794 | − | "Usage is charged after it runs. Add a card under Billing, so g1t charges it as you near your limit instead of stopping work.", | |
| 795 | − | to: workspace ? `/${workspace}/-/billing` : null, | |
| 796 | − | action: "Billing", | |
| 797 | − | } | |
| 798 | − | : { | |
| 799 | − | done: false, | |
| 800 | − | title: "Connect a model", | |
| 801 | − | about: `${ | |
| 802 | − | trialClosed(trial, workspace ?? "This workspace") ? `${trialClosed(trial, workspace ?? "This workspace")} ` : "" | |
| 803 | − | }Agents need a model to think with. Connect your Anthropic or OpenAI key, or any compatible endpoint.`, | |
| 804 | − | to: workspace ? `/${workspace}/-/integrations/models` : null, | |
| 805 | − | action: "Connect", | |
| 806 | − | }, | |
| 807 | − | { | |
| 808 | − | done: loaded.handedOff, | |
| 809 | − | title: "Hand off an outcome", | |
| 810 | − | about: | |
| 811 | − | "Open an issue and assign it to g1t, or write an outcome on a project's Plans page and let a planner split it into issues.", | |
| 812 | − | to: repos[0] ? `/${repos[0].namespace}/${repos[0].name}/plans` : null, | |
| 813 | − | action: "Write one", | |
| 814 | − | }, | |
| 815 | − | ]; | |
| 816 | − | const starting = steps.some((step) => !step.done); | |
| 817 | 712 | const noProjects = repos.length === 0; | |
| 818 | 713 | ||
| 819 | 714 | const rows = tab === "needs" ? sortRows(needs, sort) : tab === "waiting" ? sortRows(waiting, sort) : sortRows(landed, sort); | |
| ⋯ | |||
| 921 | 816 | </Link> | |
| 922 | 817 | )} | |
| 923 | 818 | </section> | |
| 924 | − | ||
| 925 | − | {starting && <GetStarted steps={steps} />} | |
| 926 | 819 | ||
| 927 | 820 | <Card asChild tone="plain" className="grid grid-cols-2 gap-px overflow-hidden bg-line lg:grid-cols-5"> | |
| 928 | 821 | <section | |
| ⋯ | |||
| 1203 | 1096 | ))} | |
| 1204 | 1097 | </ol> | |
| 1205 | 1098 | )} | |
| 1206 | − | </section> | |
| 1207 | − | </Card> | |
| 1208 | − | ||
| 1209 | − | <Card asChild className="p-5"> | |
| 1210 | − | <section> | |
| 1211 | − | <h2 className="text-sm font-semibold">Connect your own agent</h2> | |
| 1212 | − | <p className="mt-1.5 text-xs leading-5 text-muted"> | |
| 1213 | − | Add g1t to your coding agent. It signs in through your browser; there is no token to copy. | |
| 1214 | − | </p> | |
| 1215 | − | <AgentSetup className="mt-3" /> | |
| 1216 | − | <Link | |
| 1217 | − | to="https://docs.g1t.sh/guides/bring-your-own-agent/" | |
| 1218 | − | className="mt-3 inline-flex items-center gap-1 text-xs text-muted hover:text-fg" | |
| 1219 | − | > | |
| 1220 | − | How it works <ArrowRight size={12} /> | |
| 1221 | − | </Link> | |
| 1222 | 1099 | </section> | |
| 1223 | 1100 | </Card> | |
| 1224 | 1101 | </aside> | |
| 13 | 13 | import { DeploymentsPanel } from "./deployments-panel"; | |
| 14 | 14 | import { CodeLines } from "./code-lines"; | |
| 15 | 15 | ||
| 16 | − | import { AgentSetup } from "./agent-setup"; | |
| 17 | 16 | import { useAddresses } from "../lib/addresses"; | |
| 18 | 17 | import { isImagePath } from "../lib/usercontent"; | |
| 19 | 18 | import { CloneBox } from "./clone-box"; | |
| ⋯ | |||
| 356 | 355 | <CopyLine prompt text={`git remote add g1t ${cloneUrl}`} /> | |
| 357 | 356 | <CopyLine prompt text={`git push -u g1t ${ref}`} /> | |
| 358 | 357 | </div> | |
| 359 | − | <h3 className="mt-7 text-sm font-medium">Or have your coding agent start it</h3> | |
| 360 | − | <p className="mt-1 text-sm text-muted"> | |
| 361 | − | Connect it to g1t, then ask it to build the first version of{" "} | |
| 362 | − | <span className="font-mono text-fg">{repo.name}</span> and push it here. | |
| 363 | − | </p> | |
| 364 | − | <AgentSetup className="mt-3" /> | |
| 365 | 358 | </Card> | |
| 366 | 359 | ); | |
| 367 | 360 | } | |
| 1340 | 1340 | ||
| 1341 | 1341 | /** | |
| 1342 | 1342 | * People's sidebar: everyone in the workspace (its people; agents are | |
| 1343 | − | * under Agents); its teams, which agents can be on; and the org chart. Who | |
| 1344 | − | * belongs, and inviting people, is the workspace's: Workspace mode's | |
| 1345 | − | * Access group. | |
| 1343 | + | * under Agents) and its teams, which agents can be on. Who belongs, and | |
| 1344 | + | * inviting people, is the workspace's: Workspace mode's Access group. | |
| 1346 | 1345 | */ | |
| 1347 | 1346 | function PeopleSidebar({ slug }: { slug: string }) { | |
| 1348 | 1347 | const { pathname } = useLocation(); | |
| ⋯ | |||
| 1360 | 1359 | <SidebarLink to={`/${slug}/-/teams`} icon={<UsersRound size={15} />} current={at("teams")}> | |
| 1361 | 1360 | Teams | |
| 1362 | 1361 | </SidebarLink> | |
| 1363 | − | <SidebarLink to={`/${slug}/-/org-chart`} icon={<Network size={15} />} current={at("org-chart")}> | |
| 1364 | − | Org chart | |
| 1365 | − | </SidebarLink> | |
| 1366 | 1362 | </NavRows> | |
| 1367 | 1363 | </nav> | |
| 1368 | 1364 | </div> | |
| ⋯ | |||
| 1461 | 1457 | secrets: "Secrets and variables", | |
| 1462 | 1458 | settings: "Settings", | |
| 1463 | 1459 | people: "People", | |
| 1464 | − | "org-chart": "Org chart", | |
| 1465 | 1460 | members: "Members", | |
| 1466 | 1461 | permissions: "Permissions", | |
| 1467 | 1462 | projects: "Projects", | |
| 77 | 77 | ||
| 78 | 78 | Cloudflare may set its own security cookies (such as `__cf_bm`) to tell people from bots when g1t is under attack. | |
| 79 | 79 | ||
| 80 | − | The site also keeps a few preferences in your browser's local storage, which never leave your device: which coding agent you set up with, how you like diffs shown, and checklist items you've dismissed. HeyCatch keeps a copy of its analytics id there too, which is sent with each analytics event. | |
| 80 | + | The site also keeps a few preferences in your browser's local storage, which never leave your device: which coding agent you set up with and how you like diffs shown. HeyCatch keeps a copy of its analytics id there too, which is sent with each analytics event. | |
| 81 | 81 | ||
| 82 | 82 | **Fonts.** g1t.sh and docs.g1t.sh serve their typefaces themselves, so loading a page asks no one else for them. | |
| 83 | 83 |
| 1 | − | import assert from "node:assert/strict"; | |
| 2 | − | import { test } from "node:test"; | |
| 3 | − | ||
| 4 | − | import { | |
| 5 | − | type ChecklistFacts, | |
| 6 | − | agentWasAssigned, | |
| 7 | − | checklistPlan, | |
| 8 | − | dismiss, | |
| 9 | − | dismissKey, | |
| 10 | − | hasInstructions, | |
| 11 | − | isDismissed, | |
| 12 | − | productionChecklist, | |
| 13 | − | progress, | |
| 14 | − | releaseChecklist, | |
| 15 | − | startChecklist, | |
| 16 | − | } from "./checklist.ts"; | |
| 17 | − | ||
| 18 | − | const fresh: ChecklistFacts = { | |
| 19 | − | base: "/acme/web", | |
| 20 | − | hasCode: false, | |
| 21 | − | deploysEnabled: false, | |
| 22 | − | productionDeployed: false, | |
| 23 | − | domains: 0, | |
| 24 | − | previewOpened: false, | |
| 25 | − | instructions: false, | |
| 26 | − | agentAssigned: false, | |
| 27 | − | }; | |
| 28 | − | ||
| 29 | − | test("a new project has everything to do, in order", () => { | |
| 30 | − | const items = productionChecklist(fresh); | |
| 31 | − | assert.deepEqual( | |
| 32 | − | items.map((item) => item.key), | |
| 33 | − | ["code", "deploy", "domain", "preview", "instructions", "agent"], | |
| 34 | − | ); | |
| 35 | − | assert.deepEqual(progress(items), { done: 0, total: 6, complete: false }); | |
| 36 | − | }); | |
| 37 | − | ||
| 38 | − | test("each step is done from its own fact", () => { | |
| 39 | − | const items = productionChecklist({ ...fresh, hasCode: true, productionDeployed: true, domains: 2 }); | |
| 40 | − | assert.deepEqual(progress(items), { done: 3, total: 6, complete: false }); | |
| 41 | − | const all = productionChecklist({ | |
| 42 | − | ...fresh, | |
| 43 | − | hasCode: true, | |
| 44 | − | productionDeployed: true, | |
| 45 | − | domains: 1, | |
| 46 | − | previewOpened: true, | |
| 47 | − | instructions: true, | |
| 48 | − | agentAssigned: true, | |
| 49 | − | }); | |
| 50 | − | assert.equal(progress(all).complete, true); | |
| 51 | − | }); | |
| 52 | − | ||
| 53 | − | test("unknown facts count as not done", () => { | |
| 54 | − | const items = productionChecklist({ ...fresh, domains: null, instructions: null }); | |
| 55 | − | assert.equal(items.find((item) => item.key === "domain")?.done, false); | |
| 56 | − | assert.equal(items.find((item) => item.key === "instructions")?.done, false); | |
| 57 | − | }); | |
| 58 | − | ||
| 59 | − | test("each step links to where it is done", () => { | |
| 60 | − | const off = productionChecklist(fresh); | |
| 61 | − | assert.equal(off.find((item) => item.key === "deploy")?.to, "/acme/web/settings/deployments"); | |
| 62 | − | const on = productionChecklist({ ...fresh, deploysEnabled: true }); | |
| 63 | − | assert.equal(on.find((item) => item.key === "deploy")?.to, "/acme/web/deployments"); | |
| 64 | − | assert.equal(on.find((item) => item.key === "domain")?.to, "/acme/web/settings/domains"); | |
| 65 | − | assert.equal(on.find((item) => item.key === "agent")?.to, "/acme/web/issues/new"); | |
| 66 | − | }); | |
| 67 | − | ||
| 68 | − | test("instructions are AGENTS.md or CLAUDE.md at the root", () => { | |
| 69 | − | assert.equal(hasInstructions(["README.md", "AGENTS.md"]), true); | |
| 70 | − | assert.equal(hasInstructions(["claude.md"]), true); | |
| 71 | − | assert.equal(hasInstructions(["README.md", "AGENTS.mdx", "docs"]), false); | |
| 72 | − | }); | |
| 73 | − | ||
| 74 | − | test("g1t counts as assigned from a run, a pull request or an issue", () => { | |
| 75 | − | const none = { runAgents: ["claude-code"], pullAgents: ["claude-code"], issues: [{ assignees: ["ana"], agent: null }] }; | |
| 76 | − | assert.equal(agentWasAssigned(none), false); | |
| 77 | − | assert.equal(agentWasAssigned({ ...none, runAgents: ["g1t"] }), true); | |
| 78 | − | assert.equal(agentWasAssigned({ ...none, pullAgents: ["g1t"] }), true); | |
| 79 | − | assert.equal(agentWasAssigned({ ...none, issues: [{ assignees: ["G1T"], agent: null }] }), true); | |
| 80 | − | assert.equal(agentWasAssigned({ ...none, issues: [{ assignees: [], agent: "g1t" }] }), true); | |
| 81 | − | }); | |
| 82 | − | ||
| 83 | − | test("dismissing is remembered per project, and storage that throws is ignored", () => { | |
| 84 | − | const map = new Map<string, string>(); | |
| 85 | − | const store = { getItem: (key: string) => map.get(key) ?? null, setItem: (key: string, value: string) => void map.set(key, value) }; | |
| 86 | − | assert.equal(isDismissed(() => store, "/acme/web"), false); | |
| 87 | − | assert.equal(dismiss(() => store, "/Acme/Web"), true); | |
| 88 | − | assert.equal(isDismissed(() => store, "/acme/web"), true); | |
| 89 | − | assert.equal(isDismissed(() => store, "/acme/api"), false); | |
| 90 | − | assert.equal(dismissKey("/Acme/Web"), "g1t:checklist-dismissed:/acme/web"); | |
| 91 | − | const blocked = { | |
| 92 | − | getItem: () => { | |
| 93 | − | throw new Error("blocked"); | |
| 94 | − | }, | |
| 95 | − | setItem: () => { | |
| 96 | − | throw new Error("blocked"); | |
| 97 | − | }, | |
| 98 | − | }; | |
| 99 | − | assert.equal(isDismissed(() => blocked, "/acme/web"), false); | |
| 100 | − | assert.equal(dismiss(() => blocked, "/acme/web"), false); | |
| 101 | − | }); | |
| 102 | − | ||
| 103 | − | test("a library's checklist ships a release instead of deploying", () => { | |
| 104 | − | const facts = { | |
| 105 | − | base: "/flagon-io/php-log", | |
| 106 | − | hasCode: true, | |
| 107 | − | instructions: false, | |
| 108 | − | agentAssigned: false, | |
| 109 | − | hasWorkflow: false, | |
| 110 | − | released: true, | |
| 111 | − | releaseTo: "/flagon-io/-/packages/composer/psr/log", | |
| 112 | − | }; | |
| 113 | − | const items = releaseChecklist(facts); | |
| 114 | − | assert.deepEqual( | |
| 115 | − | items.map((item) => item.key), | |
| 116 | − | ["code", "checks", "release", "instructions", "agent"], | |
| 117 | − | ); | |
| 118 | − | assert.deepEqual(progress(items), { done: 2, total: 5, complete: false }); | |
| 119 | − | assert.equal(items.find((item) => item.key === "checks")?.to, "/flagon-io/php-log/actions"); | |
| 120 | − | assert.equal(items.find((item) => item.key === "release")?.to, "/flagon-io/-/packages/composer/psr/log"); | |
| 121 | − | assert.ok(items.every((item) => !/deploy|production|domain|preview/i.test(item.title))); | |
| 122 | − | // An unknown workflow list counts as not done. | |
| 123 | − | assert.equal(releaseChecklist({ ...facts, hasWorkflow: null }).find((item) => item.key === "checks")?.done, false); | |
| 124 | − | }); | |
| 125 | − | ||
| 126 | − | const start = { | |
| 127 | − | base: "/flagon-io/g1t", | |
| 128 | − | hasCode: true, | |
| 129 | − | instructions: true, | |
| 130 | − | agentAssigned: false, | |
| 131 | − | hasWorkflow: true, | |
| 132 | − | productionUrl: null, | |
| 133 | − | hasLinks: false, | |
| 134 | − | hasDocsLink: false, | |
| 135 | − | }; | |
| 136 | − | ||
| 137 | − | test("only what g1t deploys gets production's steps", () => { | |
| 138 | − | assert.deepEqual(checklistPlan({ kind: "app", runs: "g1t" }), { plan: "production", title: "Get to production" }); | |
| 139 | − | assert.deepEqual(checklistPlan({ kind: "docs", runs: "g1t" }).plan, "production"); | |
| 140 | − | assert.deepEqual(checklistPlan({ kind: "app", runs: "elsewhere" }), { plan: "elsewhere", title: "Get started" }); | |
| 141 | − | assert.equal(checklistPlan({ kind: "app", runs: null }).plan, "unknown"); | |
| 142 | − | assert.deepEqual(checklistPlan({ kind: "tool", runs: null }), { plan: "release", title: "Ship a release" }); | |
| 143 | − | assert.equal(checklistPlan({ kind: "library", runs: null }).plan, "release"); | |
| 144 | − | assert.equal(checklistPlan({ kind: "docs", runs: "elsewhere" }).plan, "docs"); | |
| 145 | − | assert.equal(checklistPlan({ kind: "other", runs: null }).plan, "other"); | |
| 146 | − | }); | |
| 147 | − | ||
| 148 | − | test("an app deployed elsewhere is never asked to deploy on g1t", () => { | |
| 149 | − | const items = startChecklist("elsewhere", start); | |
| 150 | − | assert.deepEqual( | |
| 151 | − | items.map((item) => item.key), | |
| 152 | − | ["code", "production", "checks", "instructions", "agent"], | |
| 153 | − | ); | |
| 154 | − | assert.ok(items.every((item) => !/deploy to|turn on|domain|preview/i.test(`${item.title} ${item.detail}`))); | |
| 155 | − | assert.equal(items.find((item) => item.key === "production")?.done, false); | |
| 156 | − | assert.equal(startChecklist("elsewhere", { ...start, productionUrl: "https://g1t.sh" }).find((item) => item.key === "production")?.done, true); | |
| 157 | − | // Every step can be done. | |
| 158 | − | const all = startChecklist("elsewhere", { ...start, productionUrl: "https://g1t.sh", agentAssigned: true }); | |
| 159 | − | assert.equal(progress(all).complete, true); | |
| 160 | − | }); | |
| 161 | − | ||
| 162 | − | test("an app nobody has placed asks where it runs; docs and other ask for links", () => { | |
| 163 | − | assert.deepEqual( | |
| 164 | − | startChecklist("unknown", start).map((item) => item.key), | |
| 165 | − | ["code", "where", "checks", "instructions", "agent"], | |
| 166 | − | ); | |
| 167 | − | assert.equal(startChecklist("unknown", start).find((item) => item.key === "where")?.to, "/flagon-io/g1t/settings#kind"); | |
| 168 | − | const docs = startChecklist("docs", { ...start, hasDocsLink: true }); | |
| 169 | − | assert.deepEqual(docs.map((item) => item.key), ["code", "links", "instructions", "agent"]); | |
| 170 | − | assert.equal(docs.find((item) => item.key === "links")?.done, true); | |
| 171 | − | const other = startChecklist("other", start); | |
| 172 | − | assert.equal(other.find((item) => item.key === "links")?.done, false); | |
| 173 | − | assert.equal(startChecklist("other", { ...start, hasLinks: true, agentAssigned: true }).every((item) => item.done), true); | |
| 174 | − | }); |
| 1 | − | /** | |
| 2 | − | * The steps that take a project to production, or for a library to its | |
| 3 | − | * first release, each worked out from what the project has done, with | |
| 4 | − | * where to do the ones it has not. Shown on the project's overview to its | |
| 5 | − | * members until they are all done or it is dismissed. | |
| 6 | − | */ | |
| 7 | − | ||
| 8 | − | export type ChecklistFacts = { | |
| 9 | − | /** The project's address, `/<workspace>/<project>`. */ | |
| 10 | − | base: string; | |
| 11 | − | /** Its default branch has a commit, or its source is a mirror. */ | |
| 12 | − | hasCode: boolean; | |
| 13 | − | /** Deployments are switched on for it. */ | |
| 14 | − | deploysEnabled: boolean; | |
| 15 | − | /** A production build has gone live, now or before. */ | |
| 16 | − | productionDeployed: boolean; | |
| 17 | − | /** Custom domains it has; null when they could not be read. */ | |
| 18 | − | domains: number | null; | |
| 19 | − | /** A preview has been built for a branch or pull request. */ | |
| 20 | − | previewOpened: boolean; | |
| 21 | − | /** An AGENTS.md or CLAUDE.md at the root of its default branch; null when unknown. */ | |
| 22 | − | instructions: boolean | null; | |
| 23 | − | /** g1t has been given an issue here. */ | |
| 24 | − | agentAssigned: boolean; | |
| 25 | − | }; | |
| 26 | − | ||
| 27 | − | export type ChecklistItem = { | |
| 28 | − | key: "code" | "deploy" | "domain" | "preview" | "checks" | "release" | "production" | "where" | "links" | "instructions" | "agent"; | |
| 29 | − | title: string; | |
| 30 | − | detail: string; | |
| 31 | − | done: boolean; | |
| 32 | − | to: string; | |
| 33 | − | action: string; | |
| 34 | − | }; | |
| 35 | − | ||
| 36 | − | export function productionChecklist(facts: ChecklistFacts): ChecklistItem[] { | |
| 37 | − | const { base } = facts; | |
| 38 | − | return [ | |
| 39 | − | { | |
| 40 | − | key: "code", | |
| 41 | − | title: "Connect a source or push code", | |
| 42 | − | detail: "Push an existing project, or have your coding agent start one.", | |
| 43 | − | done: facts.hasCode, | |
| 44 | − | to: `${base}/code`, | |
| 45 | − | action: "Push code", | |
| 46 | − | }, | |
| 47 | − | { | |
| 48 | − | key: "deploy", | |
| 49 | − | title: "Deploy to production", | |
| 50 | − | detail: facts.deploysEnabled | |
| 51 | − | ? "Production builds from the default branch on every push." | |
| 52 | − | : "Deployments are off until you turn them on. Then production builds from the default branch on every push.", | |
| 53 | − | done: facts.productionDeployed, | |
| 54 | − | to: facts.deploysEnabled ? `${base}/deployments` : `${base}/settings/deployments`, | |
| 55 | − | action: facts.deploysEnabled ? "Deployments" : "Turn on", | |
| 56 | − | }, | |
| 57 | − | { | |
| 58 | − | key: "domain", | |
| 59 | − | title: "Add a custom domain", | |
| 60 | − | detail: "Serve production from a domain of your own.", | |
| 61 | − | done: (facts.domains ?? 0) > 0, | |
| 62 | − | to: `${base}/settings/domains`, | |
| 63 | − | action: "Add", | |
| 64 | − | }, | |
| 65 | − | { | |
| 66 | − | key: "preview", | |
| 67 | − | title: "Open a preview", | |
| 68 | − | detail: "Every pull request gets a live preview of its branch.", | |
| 69 | − | done: facts.previewOpened, | |
| 70 | − | to: `${base}/pulls/new`, | |
| 71 | − | action: "New pull request", | |
| 72 | − | }, | |
| 73 | − | { | |
| 74 | − | key: "instructions", | |
| 75 | − | title: "Set up repository instructions", | |
| 76 | − | detail: "Commit an AGENTS.md with how to build and test; every agent run reads it.", | |
| 77 | − | done: facts.instructions === true, | |
| 78 | − | to: `${base}/agents#instructions`, | |
| 79 | − | action: "How", | |
| 80 | − | }, | |
| 81 | − | { | |
| 82 | − | key: "agent", | |
| 83 | − | title: "Assign a first issue to g1t", | |
| 84 | − | detail: "It opens a pull request, makes the change and sees it through checks and review.", | |
| 85 | − | done: facts.agentAssigned, | |
| 86 | − | to: `${base}/issues/new`, | |
| 87 | − | action: "New issue", | |
| 88 | − | }, | |
| 89 | − | ]; | |
| 90 | − | } | |
| 91 | − | ||
| 92 | − | export type ReleaseFacts = Pick<ChecklistFacts, "base" | "hasCode" | "instructions" | "agentAssigned"> & { | |
| 93 | − | /** It has a workflow, whose runs are its pull requests' checks; null when unknown. */ | |
| 94 | − | hasWorkflow: boolean | null; | |
| 95 | − | /** A package its repository publishes has a version. */ | |
| 96 | − | released: boolean; | |
| 97 | − | /** Where publishing a first version is explained: its package's page or its registry's guide. */ | |
| 98 | − | releaseTo: string; | |
| 99 | − | }; | |
| 100 | − | ||
| 101 | − | /** | |
| 102 | − | * The steps for a library or a tool, which ships as releases rather than | |
| 103 | − | * deploying: the same first and last steps as production, with checks and | |
| 104 | − | * a first version in between. | |
| 105 | − | */ | |
| 106 | − | export function releaseChecklist(facts: ReleaseFacts): ChecklistItem[] { | |
| 107 | − | const shared = productionChecklist({ ...facts, deploysEnabled: false, productionDeployed: false, domains: null, previewOpened: false }); | |
| 108 | − | const step = (key: ChecklistItem["key"]) => shared.find((item) => item.key === key)!; | |
| 109 | − | return [ | |
| 110 | − | step("code"), | |
| 111 | − | { | |
| 112 | − | key: "checks", | |
| 113 | − | title: "Add checks on pull requests", | |
| 114 | − | detail: "A workflow that builds and tests it. Its runs are every pull request's checks.", | |
| 115 | − | done: facts.hasWorkflow === true, | |
| 116 | − | to: `${facts.base}/actions`, | |
| 117 | − | action: "Add CI", | |
| 118 | − | }, | |
| 119 | − | { | |
| 120 | − | key: "release", | |
| 121 | − | title: "Tag a release or publish a package", | |
| 122 | − | detail: "Publish a first version to the workspace's registry for others to install.", | |
| 123 | − | done: facts.released, | |
| 124 | − | to: facts.releaseTo, | |
| 125 | − | action: "How", | |
| 126 | − | }, | |
| 127 | − | step("instructions"), | |
| 128 | − | step("agent"), | |
| 129 | − | ]; | |
| 130 | − | } | |
| 131 | − | ||
| 132 | − | export type StartFacts = Pick<ChecklistFacts, "base" | "hasCode" | "instructions" | "agentAssigned"> & { | |
| 133 | − | /** It has a workflow, whose runs are its pull requests' checks; null when unknown. */ | |
| 134 | − | hasWorkflow: boolean | null; | |
| 135 | − | /** Production's address, for an app deployed elsewhere. */ | |
| 136 | − | productionUrl: string | null; | |
| 137 | − | /** Its homepage, docs or any other link is set. */ | |
| 138 | − | hasLinks: boolean; | |
| 139 | − | /** Its docs address is set, or its homepage. */ | |
| 140 | − | hasDocsLink: boolean; | |
| 141 | − | }; | |
| 142 | − | ||
| 143 | − | /** Where a project's own settings are, for the steps that are done there. */ | |
| 144 | − | const settingsAt = (base: string, anchor: string) => `${base}/settings#${anchor}`; | |
| 145 | − | ||
| 146 | − | /** | |
| 147 | − | * The steps for a project g1t does not deploy, by what it is. Every step | |
| 148 | − | * applies to it and each is done from its own fact: nothing about turning | |
| 149 | − | * on Deployments, domains or previews. | |
| 150 | − | * | |
| 151 | − | * - An app deployed elsewhere: its production address, then checks. | |
| 152 | − | * - An app nobody has said where it runs: saying so, then checks. | |
| 153 | − | * - Docs: where they are read. | |
| 154 | − | * - Anything else: its links. | |
| 155 | − | */ | |
| 156 | − | export function startChecklist(kind: "elsewhere" | "unknown" | "docs" | "other", facts: StartFacts): ChecklistItem[] { | |
| 157 | − | const shared = productionChecklist({ ...facts, deploysEnabled: false, productionDeployed: false, domains: null, previewOpened: false }); | |
| 158 | − | const step = (key: ChecklistItem["key"]) => shared.find((item) => item.key === key)!; | |
| 159 | − | const checks: ChecklistItem = { | |
| 160 | − | key: "checks", | |
| 161 | − | title: "Add checks on pull requests", | |
| 162 | − | detail: "A workflow that builds and tests it. Its runs are every pull request's checks.", | |
| 163 | − | done: facts.hasWorkflow === true, | |
| 164 | − | to: `${facts.base}/actions`, | |
| 165 | − | action: "Add CI", | |
| 166 | − | }; | |
| 167 | − | const middle: ChecklistItem[] = | |
| 168 | − | kind === "elsewhere" | |
| 169 | − | ? [ | |
| 170 | − | { | |
| 171 | − | key: "production", | |
| 172 | − | title: "Add production's address", | |
| 173 | − | detail: "Where your own pipeline deploys it, so its overview links to production.", | |
| 174 | − | done: facts.productionUrl != null, | |
| 175 | − | to: settingsAt(facts.base, "kind"), | |
| 176 | − | action: "Add", | |
| 177 | − | }, | |
| 178 | − | checks, | |
| 179 | − | ] | |
| 180 | − | : kind === "unknown" | |
| 181 | − | ? [ | |
| 182 | − | { | |
| 183 | − | key: "where", | |
| 184 | − | title: "Say where it runs", | |
| 185 | − | detail: "Deployed on g1t, deployed elsewhere, or not deployed at all, such as a library.", | |
| 186 | − | done: false, | |
| 187 | − | to: settingsAt(facts.base, "kind"), | |
| 188 | − | action: "Choose", | |
| 189 | − | }, | |
| 190 | − | checks, | |
| 191 | − | ] | |
| 192 | − | : kind === "docs" | |
| 193 | − | ? [ | |
| 194 | − | { | |
| 195 | − | key: "links", | |
| 196 | − | title: "Add where its docs are read", | |
| 197 | − | detail: "A docs or homepage address, shown on its overview and wherever the project is listed.", | |
| 198 | − | done: facts.hasDocsLink, | |
| 199 | − | to: settingsAt(facts.base, "links"), | |
| 200 | − | action: "Add", | |
| 201 | − | }, | |
| 202 | − | ] | |
| 203 | − | : [ | |
| 204 | − | { | |
| 205 | − | key: "links", | |
| 206 | − | title: "Add its links", | |
| 207 | − | detail: "A homepage, docs, or any other address people go to for it.", | |
| 208 | − | done: facts.hasLinks, | |
| 209 | − | to: settingsAt(facts.base, "links"), | |
| 210 | − | action: "Add", | |
| 211 | − | }, | |
| 212 | − | ]; | |
| 213 | − | return [step("code"), ...middle, step("instructions"), step("agent")]; | |
| 214 | − | } | |
| 215 | − | ||
| 216 | − | export type ChecklistPlan = "production" | "release" | "elsewhere" | "unknown" | "docs" | "other"; | |
| 217 | − | ||
| 218 | − | /** | |
| 219 | − | * Which steps a project gets, and their heading, from what it is and where | |
| 220 | − | * it runs. Only what g1t deploys gets production's steps. | |
| 221 | − | */ | |
| 222 | − | export function checklistPlan(project: { | |
| 223 | − | kind: "app" | "library" | "tool" | "docs" | "other"; | |
| 224 | − | runs: "g1t" | "elsewhere" | null; | |
| 225 | − | }): { plan: ChecklistPlan; title: string } { | |
| 226 | − | if (project.runs === "g1t") return { plan: "production", title: "Get to production" }; | |
| 227 | − | if (project.kind === "library" || project.kind === "tool") return { plan: "release", title: "Ship a release" }; | |
| 228 | − | if (project.kind === "app") return { plan: project.runs === "elsewhere" ? "elsewhere" : "unknown", title: "Get started" }; | |
| 229 | − | return { plan: project.kind, title: "Get started" }; | |
| 230 | − | } | |
| 231 | − | ||
| 232 | − | /** `3/6`, for the card's heading. */ | |
| 233 | − | export function progress(items: ChecklistItem[]): { done: number; total: number; complete: boolean } { | |
| 234 | − | const done = items.filter((item) => item.done).length; | |
| 235 | − | return { done, total: items.length, complete: done === items.length }; | |
| 236 | − | } | |
| 237 | − | ||
| 238 | − | /** A repository's root lists instructions for agents. */ | |
| 239 | − | export function hasInstructions(names: string[]): boolean { | |
| 240 | − | return names.some((name) => /^(agents|claude)\.md$/i.test(name)); | |
| 241 | − | } | |
| 242 | − | ||
| 243 | − | /** Whether g1t has worked here, from its runs, pull requests or issues. */ | |
| 244 | − | export function agentWasAssigned(input: { | |
| 245 | − | runAgents: string[]; | |
| 246 | − | pullAgents: string[]; | |
| 247 | − | issues: { assignees: string[]; agent: string | null }[]; | |
| 248 | − | }): boolean { | |
| 249 | − | const isAgent = (name: string | null) => name?.toLowerCase() === "g1t"; | |
| 250 | − | return ( | |
| 251 | − | input.runAgents.some(isAgent) || | |
| 252 | − | input.pullAgents.some(isAgent) || | |
| 253 | − | input.issues.some((issue) => isAgent(issue.agent) || issue.assignees.some(isAgent)) | |
| 254 | − | ); | |
| 255 | − | } | |
| 256 | − | ||
| 257 | − | // --- Dismissing it, per project, in this browser ----------------------------- | |
| 258 | − | ||
| 259 | − | type Store = Pick<Storage, "getItem" | "setItem">; | |
| 260 | − | ||
| 261 | − | export function dismissKey(base: string): string { | |
| 262 | − | return `g1t:checklist-dismissed:${base.toLowerCase()}`; | |
| 263 | − | } | |
| 264 | − | ||
| 265 | − | export function isDismissed(storage: () => Store | null | undefined, base: string): boolean { | |
| 266 | − | try { | |
| 267 | − | return storage()?.getItem(dismissKey(base)) === "1"; | |
| 268 | − | } catch { | |
| 269 | − | return false; | |
| 270 | − | } | |
| 271 | − | } | |
| 272 | − | ||
| 273 | − | export function dismiss(storage: () => Store | null | undefined, base: string): boolean { | |
| 274 | − | try { | |
| 275 | − | const store = storage(); | |
| 276 | − | if (!store) return false; | |
| 277 | − | store.setItem(dismissKey(base), "1"); | |
| 278 | − | return true; | |
| 279 | − | } catch { | |
| 280 | − | return false; | |
| 281 | − | } | |
| 282 | − | } |
| 623 | 623 | workspace: slug, | |
| 624 | 624 | repos: repoList.filter((repo) => inWorkspace(repo)).map(pathOf), | |
| 625 | 625 | canRunAgents: models_ == null || models_.hosted || models_.own != null, | |
| 626 | − | trial: models_?.own == null ? (models_?.trial ?? null) : null, | |
| 627 | − | handedOff: activeList.length > 0 || runList.length > 0 || merged.length > 0, | |
| 628 | 626 | needs: needRows, | |
| 629 | 627 | waiting, | |
| 630 | 628 | landed, |
| 13 | 13 | budgetFromText, | |
| 14 | 14 | codeAccessWords, | |
| 15 | 15 | directoryEntries, | |
| 16 | − | managerChoices, | |
| 17 | − | orgChart, | |
| 18 | 16 | ownsFromText, | |
| 19 | 17 | teamBlock, | |
| 20 | 18 | teamsOfAgent, | |
| ⋯ | |||
| 31 | 29 | timezone: null, | |
| 32 | 30 | role: "member", | |
| 33 | 31 | title: null, | |
| 34 | − | manager: null, | |
| 35 | 32 | owns: [], | |
| 36 | 33 | joined_at: "2026-10-01T00:00:00Z", | |
| 37 | 34 | ...extra, | |
| ⋯ | |||
| 70 | 67 | const directory: PeopleDirectory = { | |
| 71 | 68 | people: [ | |
| 72 | 69 | person("chase", { name: "Chase Pierce", title: "Founder", role: "owner" }), | |
| 73 | − | person("sofia", { name: "Sofia Reyes", title: "Head of Sales", manager: "chase", owns: ["the forecast"] }), | |
| 74 | − | person("jordan", { name: "Jordan Lee", title: "Account Executive", manager: "sofia", owns: ["Halcyon", "Bluebird"] }), | |
| 75 | − | person("kai", { name: "Kai Nakamura", title: "Product Designer", manager: "chase" }), | |
| 70 | + | person("sofia", { name: "Sofia Reyes", title: "Head of Sales", owns: ["the forecast"] }), | |
| 71 | + | person("jordan", { name: "Jordan Lee", title: "Account Executive", owns: ["Halcyon", "Bluebird"] }), | |
| 72 | + | person("kai", { name: "Kai Nakamura", title: "Product Designer" }), | |
| 76 | 73 | ], | |
| 77 | 74 | teams: [ | |
| 78 | 75 | team("sales", "Sales", { | |
| ⋯ | |||
| 136 | 133 | ||
| 137 | 134 | test("an agent's profile is its page in Agents, where People links to it", () => { | |
| 138 | 135 | assert.equal(agentPath("acme", "margo"), "/acme/-/agents/margo"); | |
| 139 | − | }); | |
| 140 | − | ||
| 141 | − | test("the org chart follows reporting lines, with each team's agents beside its lead", () => { | |
| 142 | − | const chart = orgChart(directory, agents); | |
| 143 | − | assert.deepEqual(chart.roots.map((n) => n.person.username), ["chase"]); | |
| 144 | − | const chase = chart.roots[0]!; | |
| 145 | − | assert.deepEqual(chase.reports.map((n) => n.person.username), ["sofia", "kai"]); | |
| 146 | − | const sofia = chase.reports[0]!; | |
| 147 | − | assert.deepEqual(sofia.agents.map((a) => a.handle), ["david"]); | |
| 148 | − | assert.deepEqual(sofia.reports.map((n) => n.person.username), ["jordan"]); | |
| 149 | − | // A team an agent leads sits apart, with its agents. | |
| 150 | − | assert.deepEqual(chart.unled.map((u) => [u.team.slug, u.agents.map((a) => a.handle)]), [["night-shift", ["atlas", "sentinel", "david"]]]); | |
| 151 | − | }); | |
| 152 | − | ||
| 153 | − | test("no one goes missing from the org chart, even in a loop", () => { | |
| 154 | − | const looped: PeopleDirectory = { ...directory, people: [person("a", { manager: "b" }), person("b", { manager: "a" })] }; | |
| 155 | − | const chart = orgChart(looped, []); | |
| 156 | − | const all = (nodes: typeof chart.roots): string[] => nodes.flatMap((n) => [n.person.username, ...all(n.reports)]); | |
| 157 | − | assert.deepEqual(all(chart.roots).sort(), ["a", "b"]); | |
| 158 | − | }); | |
| 159 | − | ||
| 160 | − | test("someone can report to anyone but themselves and the people under them", () => { | |
| 161 | − | assert.deepEqual(managerChoices(directory.people, "sofia").map((p) => p.username), ["chase", "kai"]); | |
| 162 | − | assert.deepEqual(managerChoices(directory.people, "jordan").map((p) => p.username), ["chase", "sofia", "kai"]); | |
| 163 | 136 | }); | |
| 164 | 137 | ||
| 165 | 138 | test("forms read plainly", () => { | |
| 1 | 1 | /** | |
| 2 | 2 | * People on the site: the directory (people only; agents are on their | |
| 3 | − | * teams and under Agents), profiles, teams of any mix, and the org chart. | |
| 3 | + | * teams and under Agents), profiles, and teams of any mix. | |
| 4 | 4 | * Types only from the contracts, so this file runs under `node --test`. | |
| 5 | 5 | */ | |
| 6 | 6 | import type { DirectoryPerson, DirectoryTeam, PeopleDirectory, TeamLead, WorkspaceAgent } from "@g1t/contracts"; | |
| ⋯ | |||
| 119 | 119 | if (matches(query, fields)) out.push({ person, teams }); | |
| 120 | 120 | } | |
| 121 | 121 | return out; | |
| 122 | − | } | |
| 123 | − | ||
| 124 | − | /** One person in the org chart, with who reports to them and the agents of the teams they lead. */ | |
| 125 | − | export type OrgNode = { person: DirectoryPerson; agents: PeopleAgent[]; leads: DirectoryTeam[]; reports: OrgNode[] }; | |
| 126 | − | ||
| 127 | − | export type OrgChart = { | |
| 128 | − | /** Everyone at the top: no manager, or one who is no longer here. */ | |
| 129 | − | roots: OrgNode[]; | |
| 130 | − | /** Teams no person leads (an agent does, or no one), with their agents. */ | |
| 131 | − | unled: { team: DirectoryTeam; agents: PeopleAgent[] }[]; | |
| 132 | − | }; | |
| 133 | − | ||
| 134 | − | /** | |
| 135 | − | * Reporting lines, with each team's agents beside the person who leads it. | |
| 136 | − | * A loop (which identity refuses) would leave its people out of `roots`; | |
| 137 | − | * they are put back at the top, so no one is ever missing. | |
| 138 | − | */ | |
| 139 | − | export function orgChart(directory: PeopleDirectory, agents: readonly PeopleAgent[]): OrgChart { | |
| 140 | − | const known = new Set(directory.people.map((person) => person.username)); | |
| 141 | − | const byManager = new Map<string, DirectoryPerson[]>(); | |
| 142 | − | for (const person of directory.people) { | |
| 143 | − | if (person.manager && known.has(person.manager) && person.manager !== person.username) { | |
| 144 | − | byManager.set(person.manager, [...(byManager.get(person.manager) ?? []), person]); | |
| 145 | − | } | |
| 146 | − | } | |
| 147 | − | const placed = new Set<string>(); | |
| 148 | − | const node = (person: DirectoryPerson): OrgNode => { | |
| 149 | − | placed.add(person.username); | |
| 150 | − | const led = directory.teams.filter((team) => leads(team.lead, { username: person.username })); | |
| 151 | − | const theirAgents: PeopleAgent[] = []; | |
| 152 | − | for (const team of led) for (const agent of agentsOn(team, agents)) if (!theirAgents.includes(agent)) theirAgents.push(agent); | |
| 153 | − | const reports = (byManager.get(person.username) ?? []).filter((report) => !placed.has(report.username)).map(node); | |
| 154 | − | return { person, agents: theirAgents, leads: led, reports }; | |
| 155 | − | }; | |
| 156 | − | const roots = directory.people.filter((person) => !person.manager || !known.has(person.manager)).map(node); | |
| 157 | − | for (const person of directory.people) if (!placed.has(person.username)) roots.push(node(person)); | |
| 158 | − | const unled = directory.teams | |
| 159 | − | .filter((team) => !team.lead || team.lead.kind === "agent") | |
| 160 | − | .map((team) => ({ team, agents: agentsOn(team, agents) })) | |
| 161 | − | .filter((entry) => entry.agents.length > 0); | |
| 162 | − | return { roots, unled }; | |
| 163 | − | } | |
| 164 | − | ||
| 165 | − | /** Everyone who reports to `username`, directly. */ | |
| 166 | − | export function reportsOf(people: readonly DirectoryPerson[], username: string): DirectoryPerson[] { | |
| 167 | − | return people.filter((person) => person.manager === username && person.username !== username); | |
| 168 | − | } | |
| 169 | − | ||
| 170 | − | /** Who `username` could report to: anyone but themselves and the people under them. */ | |
| 171 | − | export function managerChoices(people: readonly DirectoryPerson[], username: string): DirectoryPerson[] { | |
| 172 | − | const below = new Set<string>([username]); | |
| 173 | − | let grew = true; | |
| 174 | − | while (grew) { | |
| 175 | − | grew = false; | |
| 176 | − | for (const person of people) { | |
| 177 | − | if (person.manager && below.has(person.manager) && !below.has(person.username)) { | |
| 178 | − | below.add(person.username); | |
| 179 | − | grew = true; | |
| 180 | − | } | |
| 181 | − | } | |
| 182 | − | } | |
| 183 | − | return people.filter((person) => !below.has(person.username)); | |
| 184 | 122 | } | |
| 185 | 123 | ||
| 186 | 124 | /** What someone owns, from a form's text: one per line or comma. */ | |
| 12 | 12 | libraryPackages, | |
| 13 | 13 | neverDeploys, | |
| 14 | 14 | primaryLink, | |
| 15 | + | projectPlan, | |
| 15 | 16 | projectChanges, | |
| 16 | 17 | linksFromForm, | |
| 17 | 18 | linksToShow, | |
| ⋯ | |||
| 147 | 148 | { label: "", url: "npmjs.com/x" }, | |
| 148 | 149 | ]); | |
| 149 | 150 | }); | |
| 151 | + | ||
| 152 | + | test("only what g1t deploys leads with production", () => { | |
| 153 | + | assert.equal(projectPlan({ kind: "app", runs: "g1t" }), "production"); | |
| 154 | + | assert.equal(projectPlan({ kind: "docs", runs: "g1t" }), "production"); | |
| 155 | + | assert.equal(projectPlan({ kind: "app", runs: "elsewhere" }), "elsewhere"); | |
| 156 | + | assert.equal(projectPlan({ kind: "app", runs: null }), "unknown"); | |
| 157 | + | assert.equal(projectPlan({ kind: "tool", runs: null }), "release"); | |
| 158 | + | assert.equal(projectPlan({ kind: "library", runs: null }), "release"); | |
| 159 | + | assert.equal(projectPlan({ kind: "docs", runs: "elsewhere" }), "docs"); | |
| 160 | + | assert.equal(projectPlan({ kind: "other", runs: null }), "other"); | |
| 161 | + | }); | |
| 89 | 89 | } | |
| 90 | 90 | } | |
| 91 | 91 | ||
| 92 | + | /** What a project's overview leads with: production, a release, or what it says of itself. */ | |
| 93 | + | export type ProjectPlan = "production" | "release" | "elsewhere" | "unknown" | "docs" | "other"; | |
| 94 | + | ||
| 95 | + | /** | |
| 96 | + | * Which head a project's overview shows, from what it is and where it | |
| 97 | + | * runs. Only what g1t deploys gets production's. | |
| 98 | + | */ | |
| 99 | + | export function projectPlan(project: Pick<Project, "kind" | "runs">): ProjectPlan { | |
| 100 | + | if (project.runs === "g1t") return "production"; | |
| 101 | + | if (project.kind === "library" || project.kind === "tool") return "release"; | |
| 102 | + | if (project.kind === "app") return project.runs === "elsewhere" ? "elsewhere" : "unknown"; | |
| 103 | + | return project.kind; | |
| 104 | + | } | |
| 105 | + | ||
| 92 | 106 | /** A link as pages list it: what it is, its label and address. */ | |
| 93 | 107 | export type ShownLink = { key: string; type: "homepage" | "docs" | "production" | "custom"; label: string; url: string }; | |
| 94 | 108 |
| 106 | 106 | assert.equal(modeOf("/acme/-/people/ana", "acme"), "people"); | |
| 107 | 107 | // An agent's old People address (it redirects to Agents) is still read as People's until it does. | |
| 108 | 108 | assert.equal(modeOf("/acme/-/people/agents/margo", "acme"), "people"); | |
| 109 | − | assert.equal(modeOf("/acme/-/org-chart", "acme"), "people"); | |
| 110 | 109 | // Members, invitations and permissions are the workspace's settings. | |
| 111 | 110 | assert.equal(modeOf("/acme/-/members", "acme"), "workspace"); | |
| 112 | 111 | assert.equal(modeOf("/acme/-/members/invitations", "acme"), "workspace"); |
| 317 | 317 | members: "workspace", | |
| 318 | 318 | permissions: "workspace", | |
| 319 | 319 | teams: "people", | |
| 320 | − | "org-chart": "people", | |
| 321 | 320 | workspace: "workspace", | |
| 322 | 321 | spend: "workspace", | |
| 323 | 322 | usage: "workspace", |
| 100 | 100 | index("routes/workspace/overview.tsx"), | |
| 101 | 101 | // Its pages, each in the sidebar: Overview (above), Projects, People, Insights; Teams and Packages below. | |
| 102 | 102 | route("-/projects", "routes/workspace/projects.tsx"), | |
| 103 | − | // People mode: the directory of people, each one's profile, and the org chart. Agents | |
| 103 | + | // People mode: the directory of people and each one's profile. Agents | |
| 104 | 104 | // are not in it; an agent's old People address redirects to its page in Agents. | |
| 105 | 105 | route("-/people", "routes/workspace/people.tsx"), | |
| 106 | 106 | route("-/people/agents/:handle", "routes/workspace/people/agent.tsx"), | |
| 107 | 107 | route("-/people/:username", "routes/workspace/people/person.tsx"), | |
| 108 | − | route("-/org-chart", "routes/workspace/org-chart.tsx"), | |
| 109 | 108 | // Workspace mode's Access: who is a member and their roles, the invitations sent, and who may do what. | |
| 110 | 109 | route("-/members", "routes/workspace/members.tsx"), | |
| 111 | 110 | route("-/members/invitations", "routes/workspace/member-invitations.tsx"), |
| 47 | 47 | {!overview ? ( | |
| 48 | 48 | <Card className="p-6 text-sm text-muted">Observability could not be loaded just now. Reload the page to try again.</Card> | |
| 49 | 49 | ) : nothing ? ( | |
| 50 | − | <GetStarted base={base} canManage={canManage} /> | |
| 50 | + | <NothingYet base={base} canManage={canManage} /> | |
| 51 | 51 | ) : ( | |
| 52 | 52 | <Loaded overview={overview} errors={errors} base={base} path={path} view={view} /> | |
| 53 | 53 | )} | |
| ⋯ | |||
| 314 | 314 | } | |
| 315 | 315 | ||
| 316 | 316 | /** Nothing has arrived yet: the two ways it does. */ | |
| 317 | − | function GetStarted({ base, canManage }: { base: string; canManage: boolean }) { | |
| 317 | + | function NothingYet({ base, canManage }: { base: string; canManage: boolean }) { | |
| 318 | 318 | return ( | |
| 319 | 319 | <Card asChild> | |
| 320 | 320 | <section> | |
| 45 | 45 | import { Elapsed, formatCost, useLiveRefresh } from "../../components/agents"; | |
| 46 | 46 | import { ActivityFeed, DeployStrip, Meter, NeedsList, Panel, Quiet, Unavailable, percent } from "../../components/mission"; | |
| 47 | 47 | import { type ActiveBranch, ActiveBranches } from "../../components/branches"; | |
| 48 | − | import { ProductionChecklist } from "../../components/checklist"; | |
| 49 | 48 | import { PackageIcon } from "../../components/package-icon"; | |
| 50 | 49 | import { Hint } from "../../components/ui/hint"; | |
| 51 | 50 | import { Loading, Skeleton, SkeletonRows } from "../../components/ui/skeleton"; | |
| ⋯ | |||
| 79 | 78 | runHealth, | |
| 80 | 79 | stuckMinutes, | |
| 81 | 80 | } from "../../lib/mission"; | |
| 82 | − | import { agentWasAssigned, checklistPlan, hasInstructions, productionChecklist, releaseChecklist, startChecklist } from "../../lib/checklist"; | |
| 83 | 81 | import { ECOSYSTEM_LABEL, installCommands } from "../../lib/packages"; | |
| 84 | 82 | import { | |
| 85 | 83 | PUBLISH_GUIDES, | |
| 86 | 84 | bare, | |
| 87 | − | hasRelease, | |
| 88 | 85 | kindLabel, | |
| 89 | 86 | linksToShow, | |
| 90 | 87 | latestTag, | |
| ⋯ | |||
| 92 | 89 | packageName, | |
| 93 | 90 | packagePath, | |
| 94 | 91 | projectChanges, | |
| 92 | + | projectPlan, | |
| 95 | 93 | publishGuide, | |
| 96 | 94 | } from "../../lib/project-kind"; | |
| 97 | 95 | import { AboutEditor, KindMenu, LinkList } from "../../components/project-about"; | |
| ⋯ | |||
| 150 | 148 | const soft = <T,>(promise: Promise<T> | null): Promise<T | null> => | |
| 151 | 149 | promise ? promise.catch((error) => (console.warn("overview:", error), null)) : Promise.resolve(null); | |
| 152 | 150 | // People with a role of their own here see its running parts: deployments, | |
| 153 | − | // memory, setup; everyone who can read it sees the rest. Not awaited: only | |
| 151 | + | // memory; everyone who can read it sees the rest. Not awaited: only | |
| 154 | 152 | // those sections wait on the repository lookup, which the layout makes in | |
| 155 | 153 | // this request too. | |
| 156 | 154 | const accessP = accessTo(context, params); | |
| ⋯ | |||
| 159 | 157 | memberP.then((member) => (member ? soft(start()) : null)); | |
| 160 | 158 | const repoP = soft(repoFor(context, params)); | |
| 161 | 159 | const projectP = soft(projectFor(context, params)); | |
| 162 | − | // A library or a tool shows its packages where an app shows production, | |
| 163 | − | // and every project g1t does not deploy counts a workflow in its checklist. | |
| 160 | + | // A library or a tool shows its packages where an app shows production. | |
| 164 | 161 | // Deployments from anywhere (g1t.page, g1t Actions, the API), by environment, for anyone who can read it. | |
| 165 | 162 | const environmentsP = projectP.then((found) => | |
| 166 | 163 | soft(deployments.environments(found?.ok && found.value.source.kind === "hosted" ? found.value.source.repo : path, viewer)), | |
| 167 | 164 | ); | |
| 168 | − | const planP = projectP.then((project) => (project?.ok ? checklistPlan(project.value).plan : null)); | |
| 165 | + | const planP = projectP.then((project) => (project?.ok ? projectPlan(project.value) : null)); | |
| 169 | 166 | const libraryRepoP = Promise.all([planP, repoP]).then(([plan, repo]) => (plan === "release" && repo?.ok ? repo.value : null)); | |
| 170 | 167 | const packagesP = libraryRepoP.then((repo) => (repo ? soft(packages.list(params.owner, viewer, { repoId: repo.id })) : null)); | |
| 171 | − | const workflowsP = planP.then((plan) => (plan && plan !== "production" ? forMembers(() => actions.workflows(path, viewer)) : null)); | |
| 172 | 168 | // Recent workflow runs, for Health when no pull request's checks have finished lately. | |
| 173 | 169 | const workflowRunsP = soft(actions.runs(path, viewer, { limit: 30 })); | |
| 174 | 170 | // Only the kinds the feed shows: the newest events are mostly session | |
| ⋯ | |||
| 218 | 214 | branchesP.catch(() => null), | |
| 219 | 215 | new Promise<"slow">((resolve) => setTimeout(() => resolve("slow"), crawler ? BRANCHES_WAIT_CRAWLER_MS : BRANCHES_WAIT_MS)), | |
| 220 | 216 | ]); | |
| 221 | − | const [{ insider: member, can }, project, settings, list, open, closed, log, counts, runs, queue, issues, memories, recent, mine, domains, root, packageList, workflows, tagList] = await Promise.all([ | |
| 217 | + | const [{ insider: member, can }, project, settings, list, open, closed, log, counts, runs, queue, issues, memories, recent, mine, packageList, tagList] = await Promise.all([ | |
| 222 | 218 | accessP, | |
| 223 | 219 | projectP, | |
| 224 | 220 | forMembers(() => deployments.settings(ref, viewer)), | |
| ⋯ | |||
| 234 | 230 | forMembers(() => agents.listMemories(viewer, params.owner, path)), | |
| 235 | 231 | eventsP, | |
| 236 | 232 | minePullsP, | |
| 237 | − | // For the checklist, which only members see. | |
| 238 | − | forMembers(() => deployments.domains(ref, viewer)), | |
| 239 | − | forMembers(() => repos.tree(path, viewer, null, "")), | |
| 240 | 233 | packagesP, | |
| 241 | − | workflowsP, | |
| 242 | 234 | // The latest release, for About. | |
| 243 | 235 | soft(repos.tags(path, viewer)), | |
| 244 | 236 | ]); | |
| ⋯ | |||
| 367 | 359 | const knows = distinctFacts([...memoryList.filter((m) => m.pinned), ...memoryList.filter((m) => !m.pinned).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))]).slice(0, 5); | |
| 368 | 360 | const openIssues = ok(issues); | |
| 369 | 361 | ||
| 370 | − | // --- Getting to production ---------------------------------------------------- | |
| 371 | − | const wentLive = (status: string) => status === "ready" || status === "replaced" || status === "down"; | |
| 372 | − | const liveApps = ok(list)?.live ?? []; | |
| 362 | + | // --- Where it runs ------------------------------------------------------------ | |
| 373 | 363 | const commitNow = ok(log)?.[0] ?? null; | |
| 374 | 364 | // Production deployed somewhere other than g1t.page (by g1t Actions, or | |
| 375 | 365 | // reported through the API): the slot an app deployed elsewhere shows. | |
| ⋯ | |||
| 380 | 370 | // elsewhere, runs elsewhere. | |
| 381 | 371 | const found = ok(project); | |
| 382 | 372 | const projectValue = found && deployment && found.kind === "app" && found.runs == null ? { ...found, runs: "elsewhere" as const } : found; | |
| 383 | − | const plan = projectValue ? checklistPlan(projectValue) : { plan: "production" as const, title: "Get to production" }; | |
| 384 | − | const isLibrary = plan.plan === "release"; | |
| 373 | + | const isLibrary = projectValue != null && projectPlan(projectValue) === "release"; | |
| 385 | 374 | const published = libraryPackages(ok(packageList) ?? []); | |
| 386 | − | const hasWorkflow = workflows?.ok ? workflows.value.length > 0 : null; | |
| 387 | − | const firstSteps = { | |
| 388 | − | base, | |
| 389 | − | hasCode: commitNow != null || projectValue?.source.kind === "mirror", | |
| 390 | − | instructions: commitNow == null ? false : root?.ok ? hasInstructions(root.value.entries.map((entry) => entry.name)) : null, | |
| 391 | − | agentAssigned: agentWasAssigned({ | |
| 392 | − | runAgents: runList.map((run) => run.agent), | |
| 393 | − | pullAgents: [...openPulls, ...(ok(closed) ?? [])].map((pull) => pull.agent), | |
| 394 | − | issues: openIssues ?? [], | |
| 395 | − | }), | |
| 396 | − | }; | |
| 397 | − | const links = projectValue?.links; | |
| 398 | − | const checklist = !member | |
| 399 | − | ? null | |
| 400 | − | : plan.plan !== "production" && plan.plan !== "release" | |
| 401 | − | ? startChecklist(plan.plan, { | |
| 402 | − | ...firstSteps, | |
| 403 | − | hasWorkflow, | |
| 404 | − | productionUrl: projectValue?.productionUrl ?? null, | |
| 405 | − | hasLinks: !!links && (links.homepage != null || links.docs != null || links.custom.length > 0), | |
| 406 | − | hasDocsLink: !!links && (links.docs != null || links.homepage != null), | |
| 407 | − | }) | |
| 408 | − | : isLibrary | |
| 409 | − | ? releaseChecklist({ | |
| 410 | − | ...firstSteps, | |
| 411 | − | hasWorkflow, | |
| 412 | − | released: hasRelease(published), | |
| 413 | − | releaseTo: published[0] | |
| 414 | − | ? packagePath(published[0]) | |
| 415 | − | : (publishGuide(projectValue?.ecosystem ?? null)?.guide ?? "https://docs.g1t.sh/guides/packages/"), | |
| 416 | − | }) | |
| 417 | − | : productionChecklist({ | |
| 418 | − | ...firstSteps, | |
| 419 | − | deploysEnabled: ok(settings)?.enabled ?? false, | |
| 420 | − | productionDeployed: | |
| 421 | − | liveApps.some((app) => app.kind === "production") || | |
| 422 | − | builds.some((build) => build.kind === "production" && wentLive(build.status)), | |
| 423 | − | domains: ok(domains)?.domains.length ?? null, | |
| 424 | − | previewOpened: | |
| 425 | − | liveApps.some((app) => app.kind === "preview") || builds.some((build) => build.kind === "preview" && wentLive(build.status)), | |
| 426 | − | }); | |
| 427 | 375 | ||
| 428 | 376 | // The checks on the latest commit and each active branch's head, in | |
| 429 | 377 | // one call once the branches are read: streamed in beside them. | |
| ⋯ | |||
| 442 | 390 | commit: ok(log)?.[0] ?? null, | |
| 443 | 391 | commits: (ok(log) ?? []).map((one) => ({ hash: one.hash, message: one.message.split("\n")[0] ?? "", author: one.author, coAuthors: one.coAuthors, at: one.authoredAt })), | |
| 444 | 392 | release: latestTag(ok(tagList) ?? []), | |
| 445 | − | checklistTitle: plan.title, | |
| 446 | 393 | // Production as reported from outside g1t, for an app deployed elsewhere. | |
| 447 | 394 | deployment, | |
| 448 | 395 | // Its screenshot's address on the site (production-elsewhere.ts), when it has one to show. | |
| ⋯ | |||
| 472 | 419 | knows: knows as Memory[], | |
| 473 | 420 | memoryCount: memoryList.length, | |
| 474 | 421 | memoriesLoaded: memories?.ok ?? false, | |
| 475 | − | checklist, | |
| 476 | 422 | library: isLibrary | |
| 477 | 423 | ? { packages: published.slice(0, PACKAGES_SHOWN), total: published.length, loaded: packageList?.ok ?? false } | |
| 478 | 424 | : null, | |
| ⋯ | |||
| 794 | 740 | actionData: Route.ComponentProps["actionData"]; | |
| 795 | 741 | params: Route.ComponentProps["params"]; | |
| 796 | 742 | }) { | |
| 797 | − | const { member, project, settings, builds, live, commit, open, agentsLive, columns, needs, landed, groups, health, knows, checklist, branches, library, checks } = | |
| 743 | + | const { member, project, settings, builds, live, commit, open, agentsLive, columns, needs, landed, groups, health, knows, branches, library, checks } = | |
| 798 | 744 | loaderData; | |
| 799 | 745 | const base = `/${params.owner}/${params.repo}`; | |
| 800 | 746 | // What it is to its remotes, from the repository layout. | |
| ⋯ | |||
| 804 | 750 | const productionUrl = production ? (settings?.primaryDomain ? `https://${settings.primaryDomain}` : production.url) : null; | |
| 805 | 751 | const previews = live.filter((app) => app.kind === "preview"); | |
| 806 | 752 | const latestProduction = builds.find((build) => build.kind === "production") ?? null; | |
| 807 | − | const plan = project ? checklistPlan(project).plan : "production"; | |
| 753 | + | const plan = project ? projectPlan(project) : "production"; | |
| 808 | 754 | const canChange = member && loaderData.can.manage_settings; | |
| 809 | 755 | // Its homepage and docs beside what it is, unless the card below already links there. | |
| 810 | 756 | const headUrl = | |
| ⋯ | |||
| 836 | 782 | ||
| 837 | 783 | // A mirror that is not taken over is a read-only copy (lib/mirror.ts | |
| 838 | 784 | // `offers`): what it is, where it comes from and what it holds, and | |
| 839 | − | // nothing to start. Where it runs, the checklist, Right now and the | |
| 840 | − | // agents' panels come back with a takeover. | |
| 785 | + | // nothing to start. Where it runs, Right now and the agents' panels | |
| 786 | + | // come back with a takeover. | |
| 841 | 787 | const readOnlyMirror = mirrorRepo?.mirror && !writable(mirrorRepo.mirror) ? mirrorRepo.mirror : null; | |
| 842 | 788 | if (readOnlyMirror) { | |
| 843 | 789 | return ( | |
| ⋯ | |||
| 1078 | 1024 | </p> | |
| 1079 | 1025 | )} | |
| 1080 | 1026 | </section> | |
| 1081 | − | ||
| 1082 | − | {checklist && <ProductionChecklist base={base} items={checklist} title={loaderData.checklistTitle} />} | |
| 1083 | 1027 | ||
| 1084 | 1028 | <Panel | |
| 1085 | 1029 | title="Right now" | |
| ⋯ | |||
| 1573 | 1517 | * The overview of a mirror g1t does not lead (standing by, in CI failover | |
| 1574 | 1518 | * or handing back): the remote it copies and when it last did, the facts | |
| 1575 | 1519 | * of the copy, its latest commits, and About. Nothing here starts | |
| 1576 | − | * anything: there is no place it runs, no checklist and no Right now, since | |
| 1520 | + | * anything: there is no place it runs and no Right now, since | |
| 1577 | 1521 | * work happens on the remote until someone takes over. | |
| 1578 | 1522 | */ | |
| 1579 | 1523 | function MirrorOverview({ | |
| 67 | 67 | title: "Permissions", | |
| 68 | 68 | about: "Who may do what in the workspace, part by part: repositories, teams, chat, artifacts, agents, deployments and packages. Owners only.", | |
| 69 | 69 | }, | |
| 70 | − | "org-chart": { | |
| 71 | − | title: "Org chart", | |
| 72 | − | about: "Who reports to whom, with each team's agents beside the person who leads it.", | |
| 73 | − | }, | |
| 74 | 70 | projects: { | |
| 75 | 71 | title: "Projects", | |
| 76 | 72 | about: "Everything the workspace builds and runs. Search, filter and sort them, and pin the ones you use most to keep them in your sidebar.", |
| 1 | − | import { Info } from "lucide-react"; | |
| 2 | − | import { Link, data } from "react-router"; | |
| 3 | − | ||
| 4 | − | import type { Route } from "./+types/org-chart"; | |
| 5 | − | import { AgentPersonFace, PersonFace, TeamKindMark } from "../../components/people"; | |
| 6 | − | import { Card } from "../../components/ui/card"; | |
| 7 | − | import { Hint } from "../../components/ui/hint"; | |
| 8 | − | import { page } from "../../lib/meta"; | |
| 9 | − | import { type OrgNode, agentPath, orgChart, peopleAgent, personName, personPath } from "../../lib/people"; | |
| 10 | − | import { teamPath } from "../../lib/teams"; | |
| 11 | − | import { identity, workspaceAgents } from "../../lib/services.server"; | |
| 12 | − | import { getViewer, roleIn, unwrap } from "../../lib/session.server"; | |
| 13 | − | ||
| 14 | − | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 15 | − | return page(args, { title: `Org chart · ${params.owner} · g1t` }); | |
| 16 | − | } | |
| 17 | − | ||
| 18 | − | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 19 | − | const viewer = getViewer(context); | |
| 20 | − | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 21 | − | const [directory, agents] = await Promise.all([ | |
| 22 | − | identity.peopleDirectory(viewer, params.owner).then(unwrap), | |
| 23 | − | workspaceAgents.list(params.owner, viewer!).catch(() => null), | |
| 24 | − | ]); | |
| 25 | − | return { slug: params.owner.toLowerCase(), directory, agents: agents?.ok ? agents.value.map(peopleAgent) : [] }; | |
| 26 | − | } | |
| 27 | − | ||
| 28 | − | export default function OrgChartPage({ loaderData }: Route.ComponentProps) { | |
| 29 | − | const { slug, directory, agents } = loaderData; | |
| 30 | − | const chart = orgChart(directory, agents); | |
| 31 | − | const lines = directory.people.some((person) => person.manager); | |
| 32 | − | ||
| 33 | − | return ( | |
| 34 | − | <div className="space-y-6"> | |
| 35 | − | {!lines && ( | |
| 36 | − | <Card asChild radius="lg" className="flex items-start gap-2 px-3 py-2.5 text-sm text-muted"> | |
| 37 | − | <p> | |
| 38 | − | <Info size={15} className="mt-0.5 shrink-0 text-faint" /> | |
| 39 | − | {directory.can_manage | |
| 40 | − | ? "No one reports to anyone yet. Set who each person reports to under Edit profile on their profile." | |
| 41 | − | : "No reporting lines yet. An owner sets who each person reports to on their profile."} | |
| 42 | − | </p> | |
| 43 | − | </Card> | |
| 44 | − | )} | |
| 45 | − | <Card asChild className="overflow-x-auto p-4 sm:p-5"> | |
| 46 | − | <section aria-label="Reporting lines"> | |
| 47 | − | <ul className="space-y-1"> | |
| 48 | − | {chart.roots.map((root) => ( | |
| 49 | − | <Branch key={root.person.username} slug={slug} node={root} /> | |
| 50 | − | ))} | |
| 51 | − | </ul> | |
| 52 | − | <p className="mt-4 text-xs text-faint">The agents on a team appear beside the person who leads it, as its members; each opens the agent's page in Agents.</p> | |
| 53 | − | </section> | |
| 54 | − | </Card> | |
| 55 | − | ||
| 56 | − | {chart.unled.length > 0 && ( | |
| 57 | − | <section aria-labelledby="unled" className="space-y-3"> | |
| 58 | − | <h2 id="unled" className="text-sm font-medium text-muted"> | |
| 59 | − | Teams no person leads | |
| 60 | − | </h2> | |
| 61 | − | <ul className="grid gap-3 sm:grid-cols-2"> | |
| 62 | − | {chart.unled.map(({ team, agents: teamAgents }) => ( | |
| 63 | − | <Card asChild key={team.slug} className="p-4"> | |
| 64 | − | <li> | |
| 65 | − | <div className="flex items-center gap-2"> | |
| 66 | − | <TeamKindMark people={team.people.length} agents={teamAgents.length} /> | |
| 67 | − | <Link to={teamPath(slug, team.slug)} className="font-medium hover:text-accent"> | |
| 68 | − | {team.name} | |
| 69 | − | </Link> | |
| 70 | − | {team.lead?.kind === "agent" && ( | |
| 71 | − | <span className="text-xs text-faint">led by {teamAgents.find((a) => a.id === (team.lead as { agent_id: string }).agent_id)?.display_name ?? "an agent"}</span> | |
| 72 | − | )} | |
| 73 | − | </div> | |
| 74 | − | <div className="mt-3 flex flex-wrap gap-2"> | |
| 75 | − | {teamAgents.map((agent) => ( | |
| 76 | − | <Link key={agent.id} to={agentPath(slug, agent.handle)} className="inline-flex items-center gap-1.5 rounded-full border border-line py-0.5 pr-2.5 pl-0.5 text-xs hover:border-line-strong"> | |
| 77 | − | <AgentPersonFace agent={agent} size={20} ring="var(--color-surface)" /> | |
| 78 | − | {agent.display_name} | |
| 79 | − | </Link> | |
| 80 | − | ))} | |
| 81 | − | </div> | |
| 82 | − | </li> | |
| 83 | − | </Card> | |
| 84 | − | ))} | |
| 85 | − | </ul> | |
| 86 | − | </section> | |
| 87 | − | )} | |
| 88 | − | </div> | |
| 89 | − | ); | |
| 90 | − | } | |
| 91 | − | ||
| 92 | − | /** One person, the agents of the teams they lead, and everyone who reports to them below. */ | |
| 93 | − | function Branch({ slug, node }: { slug: string; node: OrgNode }) { | |
| 94 | − | const { person } = node; | |
| 95 | − | return ( | |
| 96 | − | <li> | |
| 97 | − | <div className="flex flex-wrap items-center gap-2 py-1"> | |
| 98 | − | <Link | |
| 99 | − | to={personPath(slug, person.username)} | |
| 100 | − | prefetch="intent" | |
| 101 | − | className="inline-flex min-w-0 items-center gap-2.5 rounded-lg border border-line bg-bg py-1.5 pr-3 pl-1.5 transition-colors hover:border-accent/60" | |
| 102 | − | > | |
| 103 | − | <PersonFace person={person} size={28} /> | |
| 104 | − | <span className="grid min-w-0 text-left"> | |
| 105 | − | <span className="truncate text-sm font-medium">{personName(person)}</span> | |
| 106 | − | <span className="truncate text-xs text-muted">{person.title ?? `@${person.username}`}</span> | |
| 107 | − | </span> | |
| 108 | − | </Link> | |
| 109 | − | {node.agents.length > 0 && ( | |
| 110 | − | <span className="inline-flex items-center gap-2"> | |
| 111 | − | <span className="flex"> | |
| 112 | − | {node.agents.slice(0, 6).map((agent, index) => ( | |
| 113 | − | <Hint key={agent.id} label={`${agent.display_name} · ${agent.title || agent.role} · agent`}> | |
| 114 | − | <Link to={agentPath(slug, agent.handle)} className={index ? "-ml-1.5" : undefined} aria-label={agent.display_name}> | |
| 115 | − | <AgentPersonFace agent={agent} size={22} ring="var(--color-surface)" /> | |
| 116 | − | </Link> | |
| 117 | − | </Hint> | |
| 118 | − | ))} | |
| 119 | − | </span> | |
| 120 | − | <span className="text-xs text-faint"> | |
| 121 | − | {node.agents.length > 6 ? `+${node.agents.length - 6} · ` : ""} | |
| 122 | − | {node.leads.map((team) => team.name).join(", ")} | |
| 123 | − | </span> | |
| 124 | − | </span> | |
| 125 | − | )} | |
| 126 | − | </div> | |
| 127 | − | {node.reports.length > 0 && ( | |
| 128 | − | <ul className="ml-[17px] space-y-1 border-l border-dashed border-line-strong pl-5 sm:pl-7"> | |
| 129 | − | {node.reports.map((report) => ( | |
| 130 | − | <Branch key={report.person.username} slug={slug} node={report} /> | |
| 131 | − | ))} | |
| 132 | − | </ul> | |
| 133 | − | )} | |
| 134 | − | </li> | |
| 135 | − | ); | |
| 136 | − | } |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/overview"; | |
| 7 | 7 | import { DeployLink, host, StatusDot } from "../../components/deploy"; | |
| 8 | − | import { ButtonLink, CopyLine, TimeAgo } from "../../components/ui"; | |
| 8 | + | import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui"; | |
| 9 | 9 | import { Avatar } from "../../components/ui/avatar"; | |
| 10 | 10 | import { Badge } from "../../components/ui/badge"; | |
| 11 | 11 | import { Card } from "../../components/ui/card"; | |
| 12 | 12 | import { UsageCard } from "../../components/usage-card"; | |
| 13 | 13 | import { PullIcon } from "../../components/work-icons"; | |
| 14 | 14 | import { openedBy } from "../../lib/opened-by"; | |
| 15 | − | import { cloneUrl, useAddresses } from "../../lib/addresses"; | |
| 16 | 15 | import { kindLabel, libraryPackages, packageLine, packagePath, primaryLink } from "../../lib/project-kind"; | |
| 17 | 16 | import { PinButton } from "../../components/pin-button"; | |
| 18 | 17 | import { sortProjects } from "../../lib/project-list"; | |
| ⋯ | |||
| 118 | 117 | } | |
| 119 | 118 | ||
| 120 | 119 | /** What a member sees in a workspace with no projects yet. */ | |
| 121 | − | function GetStarted({ slug }: { slug: string }) { | |
| 122 | − | const addresses = useAddresses(); | |
| 120 | + | function NoProjects({ slug }: { slug: string }) { | |
| 123 | 121 | return ( | |
| 124 | − | <div className="rounded-2xl border border-line bg-surface p-8"> | |
| 125 | − | <span className="flex size-10 items-center justify-center rounded-xl bg-accent/10 text-accent ring-1 ring-accent/30"> | |
| 126 | − | <Box size={18} /> | |
| 127 | − | </span> | |
| 128 | − | <h2 className="mt-4 text-lg font-semibold tracking-tight">Create your first project</h2> | |
| 129 | − | <p className="mt-1.5 max-w-xl text-sm text-muted"> | |
| 130 | − | A project is what you build and run: its code, the issues that say what should change, the agents and | |
| 131 | − | people who change it, and where it is deployed. | |
| 132 | − | </p> | |
| 133 | − | <div className="mt-6 flex flex-wrap gap-3"> | |
| 134 | − | <ButtonLink to={`/new?workspace=${slug}`} variant="accent"> | |
| 135 | − | <Plus size={14} /> | |
| 136 | − | New project | |
| 137 | − | </ButtonLink> | |
| 138 | − | </div> | |
| 139 | − | <p className="mt-6 text-sm text-muted">Or push code you already have, and it becomes a project:</p> | |
| 140 | − | <div className="mt-2 max-w-xl"> | |
| 141 | − | <CopyLine prompt text={`git push ${cloneUrl(addresses, `${slug}/my-project`)} main`} /> | |
| 142 | − | </div> | |
| 143 | − | </div> | |
| 122 | + | <EmptyState title="No projects yet"> | |
| 123 | + | <ButtonLink to={`/new?workspace=${slug}`} variant="accent" className="mt-3"> | |
| 124 | + | <Plus size={14} /> | |
| 125 | + | New project | |
| 126 | + | </ButtonLink> | |
| 127 | + | </EmptyState> | |
| 144 | 128 | ); | |
| 145 | 129 | } | |
| 146 | 130 | ||
| ⋯ | |||
| 284 | 268 | <div className="grid gap-10 lg:grid-cols-[1fr_18rem]"> | |
| 285 | 269 | <div className="min-w-0 space-y-10"> | |
| 286 | 270 | {total === 0 && role ? ( | |
| 287 | − | <GetStarted slug={slug} /> | |
| 271 | + | <NoProjects slug={slug} /> | |
| 288 | 272 | ) : ( | |
| 289 | 273 | <> | |
| 290 | 274 | <div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> | |
| 1 | − | import { Bot, Network, Search, UserPlus } from "lucide-react"; | |
| 1 | + | import { Bot, Search, UserPlus } from "lucide-react"; | |
| 2 | 2 | import { Link, data, useSearchParams, type ShouldRevalidateFunctionArgs } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/people"; | |
| ⋯ | |||
| 70 | 70 | <span role="status" className="mr-1 text-sm text-muted tabular-nums"> | |
| 71 | 71 | {query ? `${entries.length} of ${total}` : total} {total === 1 && !query ? "person" : "people"} | |
| 72 | 72 | </span> | |
| 73 | − | <ButtonLink to={`/${slug}/-/org-chart`} variant="outline"> | |
| 74 | − | <Network size={15} /> | |
| 75 | − | Org chart | |
| 76 | − | </ButtonLink> | |
| 77 | 73 | {directory.can_manage && ( | |
| 78 | 74 | <ButtonLink to={`/${slug}/-/members`}> | |
| 79 | 75 | <UserPlus size={15} /> | |
| 1 | − | import { Bot, ChevronRight, KeyRound, MapPin, MessageSquare, Network, Pencil, UsersRound, Wallet } from "lucide-react"; | |
| 1 | + | import { Bot, ChevronRight, KeyRound, MapPin, MessageSquare, Pencil, UsersRound, Wallet } from "lucide-react"; | |
| 2 | 2 | import { type ReactNode, useEffect, useState } from "react"; | |
| 3 | 3 | import { Form, Link, data, redirect, useNavigation } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import type { DirectoryPerson } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/person"; | |
| 8 | − | import { AgentLink, AgentPersonFace, Coming, LocalTime, PersonFace, PersonLink, TeamKindMark } from "../../../components/people"; | |
| 8 | + | import { AgentLink, AgentPersonFace, Coming, LocalTime, PersonFace, TeamKindMark } from "../../../components/people"; | |
| 9 | 9 | import { PresenceSummary } from "../../../components/presence"; | |
| 10 | 10 | import { ErrorText, Field, Input, SubmitButton, Textarea } from "../../../components/ui"; | |
| 11 | 11 | import { Badge } from "../../../components/ui/badge"; | |
| 12 | 12 | import { Button } from "../../../components/ui/button"; | |
| 13 | 13 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../../components/ui/dialog"; | |
| 14 | − | import { SelectField } from "../../../components/ui/select"; | |
| 15 | 14 | import { channelPath } from "../../../lib/chat"; | |
| 16 | 15 | import { page } from "../../../lib/meta"; | |
| 17 | 16 | import { | |
| 18 | 17 | agentsOn, | |
| 19 | 18 | codeAccessWords, | |
| 20 | 19 | leads, | |
| 21 | − | managerChoices, | |
| 22 | 20 | ownsFromText, | |
| 23 | 21 | peopleAgent, | |
| 24 | 22 | personName, | |
| 25 | − | reportsOf, | |
| 26 | 23 | teamsOfPerson, | |
| 27 | 24 | } from "../../../lib/people"; | |
| 28 | 25 | import { teamPath } from "../../../lib/teams"; | |
| ⋯ | |||
| 35 | 32 | return page(args, { title: `${name} · People · ${params.owner} · g1t` }); | |
| 36 | 33 | } | |
| 37 | 34 | ||
| 38 | − | /** The few fields of everyone else a profile shows. */ | |
| 39 | − | function slim(person: DirectoryPerson) { | |
| 40 | − | const { user_id, username, display_username, name, avatar, title, manager } = person; | |
| 41 | − | return { user_id, username, display_username, name, avatar, title, manager }; | |
| 42 | − | } | |
| 43 | − | ||
| 44 | 35 | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 45 | 36 | const viewer = getViewer(context); | |
| 46 | 37 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| ⋯ | |||
| 60 | 51 | return { | |
| 61 | 52 | slug: params.owner.toLowerCase(), | |
| 62 | 53 | person, | |
| 63 | − | people: directory.people.map(slim), | |
| 64 | 54 | teams, | |
| 65 | 55 | agents: all, | |
| 66 | 56 | base: directory.base_permission, | |
| ⋯ | |||
| 84 | 74 | throw redirect(channelPath(params.owner.toLowerCase(), opened.value)); | |
| 85 | 75 | } | |
| 86 | 76 | if (intent === "profile") { | |
| 87 | − | const changes: { title?: string; owns?: string[]; manager?: string } = { | |
| 77 | + | const changes = { | |
| 88 | 78 | title: String(form.get("title") ?? ""), | |
| 89 | 79 | owns: ownsFromText(String(form.get("owns") ?? "")), | |
| 90 | 80 | }; | |
| 91 | − | if (form.has("manager")) { | |
| 92 | − | const manager = String(form.get("manager") ?? ""); | |
| 93 | − | changes.manager = manager === "none" ? "" : manager; | |
| 94 | − | } | |
| 95 | 81 | const saved = await identity.setMemberProfile(user, params.owner, params.username, changes); | |
| 96 | 82 | return saved.ok ? { intent, saved: true as const, error: null } : { intent, error: saved.error.message }; | |
| 97 | 83 | } | |
| ⋯ | |||
| 99 | 85 | } | |
| 100 | 86 | ||
| 101 | 87 | export default function PersonProfile({ loaderData, actionData }: Route.ComponentProps) { | |
| 102 | − | const { slug, person, people, teams, agents, base, canManage, self, spend } = loaderData; | |
| 103 | − | const manager = person.manager ? people.find((p) => p.username === person.manager) : null; | |
| 104 | − | const reports = reportsOf(people as DirectoryPerson[], person.username); | |
| 88 | + | const { slug, person, teams, agents, base, canManage, self, spend } = loaderData; | |
| 105 | 89 | const teamAgents = teams.flatMap((team) => agentsOn(team, agents).map((agent) => ({ agent, team }))); | |
| 106 | 90 | const seen = new Set<string>(); | |
| 107 | 91 | const uniqueAgents = teamAgents.filter(({ agent }) => (seen.has(agent.id) ? false : (seen.add(agent.id), true))); | |
| ⋯ | |||
| 154 | 138 | </SubmitButton> | |
| 155 | 139 | </Form> | |
| 156 | 140 | )} | |
| 157 | − | {mayEdit && <EditProfile person={person} people={people as DirectoryPerson[]} canManage={canManage} actionData={actionData} />} | |
| 141 | + | {mayEdit && <EditProfile person={person} actionData={actionData} />} | |
| 158 | 142 | </div> | |
| 159 | 143 | </header> | |
| 160 | 144 | {actionData?.intent === "message" && <ErrorText>{actionData.error}</ErrorText>} | |
| ⋯ | |||
| 198 | 182 | )} | |
| 199 | 183 | </Card> | |
| 200 | 184 | ||
| 201 | − | <Card icon={<Network size={14} />} title="Reporting line"> | |
| 202 | − | <dl className="space-y-3 text-sm"> | |
| 203 | − | <div> | |
| 204 | − | <dt className="text-xs text-faint">Reports to</dt> | |
| 205 | − | <dd className="mt-1">{manager ? <PersonRow slug={slug} person={manager} /> : <Quiet>No one{canManage ? ". Set it under Edit profile." : "."}</Quiet>}</dd> | |
| 206 | − | </div> | |
| 207 | − | <div> | |
| 208 | − | <dt className="text-xs text-faint"> | |
| 209 | − | Reports <span className="tabular-nums">{reports.length}</span> | |
| 210 | − | </dt> | |
| 211 | − | <dd className="mt-1 space-y-1.5"> | |
| 212 | − | {reports.length ? reports.map((report) => <PersonRow key={report.username} slug={slug} person={report} />) : <Quiet>No one reports to them.</Quiet>} | |
| 213 | − | </dd> | |
| 214 | − | </div> | |
| 215 | − | </dl> | |
| 216 | − | </Card> | |
| 217 | − | ||
| 218 | 185 | <Card icon={<Bot size={14} />} title="Agents on their teams"> | |
| 219 | 186 | {uniqueAgents.length ? ( | |
| 220 | 187 | <ul className="space-y-2"> | |
| ⋯ | |||
| 285 | 252 | return <p className="text-sm text-faint">{children}</p>; | |
| 286 | 253 | } | |
| 287 | 254 | ||
| 288 | − | function PersonRow({ slug, person }: { slug: string; person: Pick<DirectoryPerson, "user_id" | "username" | "display_username" | "name" | "avatar" | "title"> }) { | |
| 289 | − | return ( | |
| 290 | − | <span className="flex min-w-0 items-center gap-2.5"> | |
| 291 | − | <PersonFace person={person} size={24} ring="var(--color-surface)" /> | |
| 292 | − | <span className="min-w-0 truncate"> | |
| 293 | − | <PersonLink workspace={slug} person={person} className="font-medium" /> | |
| 294 | − | {person.title && <span className="text-muted"> · {person.title}</span>} | |
| 295 | − | </span> | |
| 296 | − | </span> | |
| 297 | − | ); | |
| 298 | − | } | |
| 299 | − | ||
| 300 | − | /** Their title and what they own (theirs, or an owner's to change), and who they report to (an owner's). */ | |
| 255 | + | /** Their title and what they own: theirs, or an owner's, to change. */ | |
| 301 | 256 | function EditProfile({ | |
| 302 | 257 | person, | |
| 303 | − | people, | |
| 304 | − | canManage, | |
| 305 | 258 | actionData, | |
| 306 | 259 | }: { | |
| 307 | 260 | person: DirectoryPerson; | |
| 308 | − | people: DirectoryPerson[]; | |
| 309 | − | canManage: boolean; | |
| 310 | 261 | actionData: { intent: string; error: string | null; saved?: true } | undefined; | |
| 311 | 262 | }) { | |
| 312 | 263 | const [open, setOpen] = useState(false); | |
| ⋯ | |||
| 314 | 265 | useEffect(() => { | |
| 315 | 266 | if (saved) setOpen(false); | |
| 316 | 267 | }, [saved, actionData]); | |
| 317 | − | const choices = managerChoices(people, person.username); | |
| 318 | 268 | return ( | |
| 319 | 269 | <> | |
| 320 | 270 | <Button variant="outline" onClick={() => setOpen(true)}> | |
| ⋯ | |||
| 335 | 285 | <Field label="What they own" hint="One per line: a product, a process, an account. Agents ask them about these."> | |
| 336 | 286 | <Textarea name="owns" defaultValue={person.owns.join("\n")} rows={3} placeholder={"storefront\nthe release process"} /> | |
| 337 | 287 | </Field> | |
| 338 | − | {canManage && ( | |
| 339 | − | <label className="block"> | |
| 340 | − | <span className="mb-1.5 block text-sm font-medium text-muted">Reports to</span> | |
| 341 | − | <SelectField | |
| 342 | − | name="manager" | |
| 343 | − | defaultValue={person.manager ?? "none"} | |
| 344 | − | className="w-full" | |
| 345 | − | options={[{ value: "none", label: "No one" }, ...choices.map((p) => ({ value: p.username, label: `${personName(p)}${p.title ? ` · ${p.title}` : ""}` }))]} | |
| 346 | − | /> | |
| 347 | − | </label> | |
| 348 | − | )} | |
| 349 | 288 | {actionData?.intent === "profile" && <ErrorText>{actionData.error}</ErrorText>} | |
| 350 | 289 | <DialogFooter> | |
| 351 | 290 | <Button variant="outline" type="button" onClick={() => setOpen(false)}> | |
| 450 | 450 | as a member that owners and the team's maintainers add and remove; nothing | |
| 451 | 451 | on the agent names a team. Every reply and session step, an agent is | |
| 452 | 452 | told each visible team it is on: who leads it, each person's title, what | |
| 453 | − | they own, who they report to, whether they are around and their local | |
| 454 | − | time, and who to ask when a person is needed. Ask the person who owns | |
| 455 | − | something before guessing. The directory of people is | |
| 456 | − | `g1t.sh/<owner>/-/people` (people only; a team's page lists the agents on | |
| 457 | − | it), profiles `-/people/<username>`, the org chart `-/org-chart`; an | |
| 458 | − | agent's page is `-/agents/<handle>`. Guide: | |
| 453 | + | they own, whether they are around and their local time, and who to ask | |
| 454 | + | when a person is needed. Ask the person who owns something before | |
| 455 | + | guessing. The directory of people is `g1t.sh/<owner>/-/people` (people | |
| 456 | + | only; a team's page lists the agents on it), profiles | |
| 457 | + | `-/people/<username>`; an agent's page is `-/agents/<handle>`. Guide: | |
| 459 | 458 | https://docs.g1t.sh/guides/people-and-teams/ | |
| 460 | 459 | ||
| 461 | 460 | Anyone creates an agent by describing it at `g1t.sh/<owner>/-/agents/new`: |
| 1 | 1 | //! People: a workspace's directory of everyone in it, people and agents, | |
| 2 | − | //! and what each person's place is: a title, who they report to, and what | |
| 3 | − | //! they own. Kept by identity beside membership; agents come from the | |
| 2 | + | //! and what each person's place is: a title and what they own. Kept by | |
| 3 | + | //! identity beside membership; agents come from the | |
| 4 | 4 | //! agents service, which identity names only by id. | |
| 5 | 5 | //! | |
| 6 | 6 | //! **Profiles.** A member's title and what they own are theirs to write, | |
| 7 | − | //! and the workspace's owners can write anyone's. Who someone reports to | |
| 8 | − | //! (their manager) is set by owners: always another member, never | |
| 9 | − | //! themselves, and never a loop. | |
| 7 | + | //! and the workspace's owners can write anyone's. | |
| 10 | 8 | //! | |
| 11 | 9 | //! **Agents on teams.** A team can have agents as well as people: mixed, | |
| 12 | 10 | //! people only or agents only. An agent is on a team when it was added to | |
| ⋯ | |||
| 27 | 25 | pub const MAX_OWNS: usize = 8; | |
| 28 | 26 | /// The longest of them. | |
| 29 | 27 | pub const MAX_OWNS_LENGTH: usize = 60; | |
| 30 | − | /// How far up a reporting line is followed, looking for a loop. | |
| 31 | − | pub const MAX_CHAIN: usize = 64; | |
| 32 | 28 | ||
| 33 | − | /// One member, as the directory, their profile and the org chart show them. | |
| 29 | + | /// One member, as the directory and their profile show them. | |
| 34 | 30 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 35 | 31 | pub struct DirectoryPerson { | |
| 36 | 32 | pub user_id: String, | |
| ⋯ | |||
| 50 | 46 | pub role: Role, | |
| 51 | 47 | /// Their title in this workspace. | |
| 52 | 48 | pub title: Option<String>, | |
| 53 | − | /// Who they report to, by username. | |
| 54 | − | pub manager: Option<String>, | |
| 55 | 49 | /// What they own: a few short phrases. | |
| 56 | 50 | pub owns: Vec<String>, | |
| 57 | 51 | /// When they joined the workspace (RFC 3339). | |
| ⋯ | |||
| 65 | 59 | pub role: TeamRole, | |
| 66 | 60 | } | |
| 67 | 61 | ||
| 68 | − | /// A team as the directory and the org chart need it: who is on it, by | |
| 69 | − | /// username and agent id, and who leads it. | |
| 62 | + | /// A team as the directory needs it: who is on it, by username and agent | |
| 63 | + | /// id, and who leads it. | |
| 70 | 64 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 71 | 65 | pub struct DirectoryTeam { | |
| 72 | 66 | pub slug: String, | |
| ⋯ | |||
| 102 | 96 | /// What members get on every repository: `none`, `read`, `write` or `admin`. | |
| 103 | 97 | pub base_permission: String, | |
| 104 | 98 | /// Whether the viewer owns the workspace: they can set anyone's | |
| 105 | − | /// title, manager and what they own. | |
| 99 | + | /// title and what they own. | |
| 106 | 100 | pub can_manage: bool, | |
| 107 | 101 | } | |
| 108 | 102 | ||
| 109 | − | /// `set_member_profile`: what is given changes; the rest stays. `manager` | |
| 110 | − | /// set to an empty string clears it. Returns `Outcome<DirectoryPerson>`. | |
| 103 | + | /// `set_member_profile`: what is given changes; the rest stays. Returns | |
| 104 | + | /// `Outcome<DirectoryPerson>`. | |
| 111 | 105 | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 112 | 106 | pub struct SetMemberProfileArgs { | |
| 113 | 107 | pub actor: User, | |
| ⋯ | |||
| 115 | 109 | pub username: String, | |
| 116 | 110 | #[serde(default)] | |
| 117 | 111 | pub title: Option<String>, | |
| 118 | − | /// A member's username, or an empty string for none. Owners only. | |
| 119 | 112 | #[serde(default)] | |
| 120 | − | pub manager: Option<String>, | |
| 121 | − | #[serde(default)] | |
| 122 | 113 | pub owns: Option<Vec<String>>, | |
| 123 | 114 | #[serde(default)] | |
| 124 | 115 | pub surface: Option<crate::audit::Surface>, | |
| ⋯ | |||
| 151 | 142 | out | |
| 152 | 143 | } | |
| 153 | 144 | ||
| 154 | − | /// What may change on someone's profile: their title and what they own | |
| 155 | − | /// by themselves or an owner; their manager by an owner. | |
| 145 | + | /// What may change on someone's profile: their title and what they own, | |
| 146 | + | /// by themselves or an owner. | |
| 156 | 147 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 157 | 148 | pub enum ProfileField { | |
| 158 | 149 | Title, | |
| 159 | 150 | Owns, | |
| 160 | − | Manager, | |
| 161 | 151 | } | |
| 162 | 152 | ||
| 163 | 153 | /// Whether `actor` may change `field` on someone's profile. | |
| 164 | 154 | pub fn may_edit_profile(field: ProfileField, is_self: bool, owner: bool) -> bool { | |
| 165 | 155 | match field { | |
| 166 | 156 | ProfileField::Title | ProfileField::Owns => is_self || owner, | |
| 167 | − | ProfileField::Manager => owner, | |
| 168 | 157 | } | |
| 169 | 158 | } | |
| 170 | 159 | ||
| 171 | − | /// Whether making `manager` the manager of `person` would make a loop. | |
| 172 | − | /// `manager_of` gives each person's current manager. | |
| 173 | − | pub fn makes_loop(person: &str, manager: &str, manager_of: impl Fn(&str) -> Option<String>) -> bool { | |
| 174 | − | if person == manager { | |
| 175 | − | return true; | |
| 176 | − | } | |
| 177 | − | let mut at = manager.to_owned(); | |
| 178 | − | for _ in 0..MAX_CHAIN { | |
| 179 | − | match manager_of(&at) { | |
| 180 | − | Some(next) if next == person => return true, | |
| 181 | − | Some(next) => at = next, | |
| 182 | − | None => return false, | |
| 183 | − | } | |
| 184 | − | } | |
| 185 | − | // A chain this long is treated as a loop rather than followed further. | |
| 186 | − | true | |
| 187 | − | } | |
| 188 | − | ||
| 189 | 160 | #[cfg(test)] | |
| 190 | 161 | mod tests { | |
| 191 | 162 | use super::*; | |
| 192 | − | use std::collections::HashMap; | |
| 193 | 163 | ||
| 194 | 164 | #[test] | |
| 195 | 165 | fn titles_are_tidied() { | |
| ⋯ | |||
| 216 | 186 | } | |
| 217 | 187 | ||
| 218 | 188 | #[test] | |
| 219 | − | fn people_write_their_own_and_owners_set_managers() { | |
| 189 | + | fn people_write_their_own_and_owners_anyones() { | |
| 220 | 190 | assert!(may_edit_profile(ProfileField::Title, true, false)); | |
| 221 | 191 | assert!(may_edit_profile(ProfileField::Owns, false, true)); | |
| 222 | 192 | assert!(!may_edit_profile(ProfileField::Owns, false, false)); | |
| 223 | − | assert!(!may_edit_profile(ProfileField::Manager, true, false)); | |
| 224 | − | assert!(may_edit_profile(ProfileField::Manager, false, true)); | |
| 225 | − | } | |
| 226 | − | ||
| 227 | − | #[test] | |
| 228 | − | fn reporting_lines_never_loop() { | |
| 229 | − | // ana -> priya -> chase | |
| 230 | − | let managers: HashMap<&str, &str> = HashMap::from([("ana", "priya"), ("priya", "chase")]); | |
| 231 | − | let of = |id: &str| managers.get(id).map(|m| (*m).to_owned()); | |
| 232 | − | assert!(makes_loop("chase", "ana", of)); | |
| 233 | − | assert!(makes_loop("priya", "priya", of)); | |
| 234 | − | assert!(!makes_loop("ana", "chase", of)); | |
| 235 | − | assert!(!makes_loop("dev", "priya", of)); | |
| 236 | 193 | } | |
| 237 | 194 | } | |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…