People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.
| 5427 | 5427 | parent: words("parent"), | |
| 5428 | 5428 | notify: if self == Op::UpdateTeam { yes(input, "notify") } else { None }, | |
| 5429 | 5429 | review_assignment: review, | |
| 5430 | + | lead: None, | |
| 5431 | + | channel_id: None, | |
| 5432 | + | channel_name: None, | |
| 5433 | + | budget_micros: None, | |
| 5430 | 5434 | surface: Some(services.audit.surface), | |
| 5431 | 5435 | }; | |
| 5432 | 5436 | if args.name.is_none() |
| 178 | 178 | { label: 'Workspaces and tokens', slug: 'guides/workspaces' }, | |
| 179 | 179 | { label: 'Access and roles', slug: 'guides/access-and-roles' }, | |
| 180 | 180 | { label: 'Teams', slug: 'guides/teams' }, | |
| 181 | + | { label: 'People and teams', slug: 'guides/people-and-teams' }, | |
| 181 | 182 | { label: 'Managing a repository', slug: 'guides/managing-repositories' }, | |
| 182 | 183 | { label: 'Releases', slug: 'guides/releases' }, | |
| 183 | 184 | { label: 'Transferring a repository', slug: 'guides/transferring-repositories' }, |
| 147 | 147 | ||
| 148 | 148 | Owners always have Admin, whatever it says. Only owners can change it: | |
| 149 | 149 | ||
| 150 | − | 1. Open the workspace's **People** in the sidebar, `g1t.sh/<workspace>/-/people`. Every member can see it; owners manage it. | |
| 150 | + | 1. Open **People → Members and invites**, `g1t.sh/<workspace>/-/members#base-permission`. Every member can see it; owners manage it. | |
| 151 | 151 | 2. Under **Base permission**, choose one. | |
| 152 | 152 | ||
| 153 | 153 | It takes effect on everyone's next request. To give one member more on | |
| ⋯ | |||
| 253 | 253 | ||
| 254 | 254 | Owners see every outside collaborator, and the repositories and roles each | |
| 255 | 255 | has, on the **Outside collaborators** tab of the workspace's | |
| 256 | − | **People**. **Invite as a member** sends one an invitation to join the | |
| 256 | + | **Members and invites** page, `g1t.sh/<workspace>/-/members`. **Invite as a member** sends one an invitation to join the | |
| 257 | 257 | workspace as a member (see [add people](/guides/workspaces/#add-people)); | |
| 258 | 258 | once they accept, the roles they have stay, and the base permission adds | |
| 259 | 259 | to them. | |
| 1 | 1 | --- | |
| 2 | 2 | title: Agent budgets and spend | |
| 3 | − | description: One monthly budget for every agent together, a budget per agent, and a cap per session. Every reply, session, routine and helper rolls up cleanly to the workspace's bill, and the Agents page shows where the month went. | |
| 3 | + | description: One monthly budget for every agent together, a budget per person, per team and per agent, and a cap per session. Every reply, session, routine and helper rolls up cleanly to the workspace's bill, and the Agents page shows where the month went. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | Agents are for everyone in the company: engineers, support, sales. Their | |
| ⋯ | |||
| 18 | 18 | | **The workspace's spend limit and AI credit** | Owners and billing managers, under [Billing](/guides/usage-and-billing/#your-spend-limit). | A hard stop for everything, agents included. | | |
| 19 | 19 | | **The agent budget** | Owners, under **Agents → Budget**. | Every agent's work together, each month. At 100% no agent takes new work until the 1st, or until an owner raises it. | | |
| 20 | 20 | | **A person's budget** | Owners, under [Workspace → Spend](/guides/spend/#budgets): a default for everyone, and a budget of their own for anyone. | What agents spend on the work one person asks for, each month. At 100% agents take no new work for that person until the 1st, and say so where they were asked. | | |
| 21 | + | | **A team's budget** | Owners and the team's maintainers, under the team's **Settings**, [**Lead, channel and budget**](/guides/teams/#lead-channel-and-budget). | What the team's agents, added and home team, spend together each month. At 100% the agents on that team take no new work until the 1st, and say: *Backend's agents have used the team's budget of $150.00 for this month. Someone who manages the team can raise it on its settings.* | | |
| 21 | 22 | | **An agent's own budget** | Owners, on the agent's **Profile**. | Monthly, and optionally daily. At 100% that agent takes no new work. | | |
| 22 | 23 | | **A session's cap** | The workspace's session cap, or the agent's lower per-session cap. | The session stops at **Needs approval** until an owner approves more. | | |
| 23 | 24 | ||
| 24 | 25 | Months and days are UTC. An idle agent costs nothing. | |
| 25 | 26 | ||
| 27 | + | An agent on several teams with budgets is held to each of them. Its work | |
| 28 | + | counts towards every team it is on, and towards its own budget, at once. | |
| 29 | + | A team's header shows what its agents spent this month against its | |
| 30 | + | budget. | |
| 31 | + | ||
| 26 | 32 | ## The agent budget | |
| 27 | 33 | ||
| 28 | 34 | Owners set it from the **Budget** button on the Agents page: | |
| 166 | 166 | with is never lost. | |
| 167 | 167 | ||
| 168 | 168 | <Aside type="note" title="Teams"> | |
| 169 | − | An agent's team says where it belongs and how lists group it. Getting the | |
| 170 | − | team's channels, mentions and review requests, the way a person on the | |
| 169 | + | When **Team** names one of the workspace's teams, that is the agent's | |
| 170 | + | **home team**: it shows on the team's page, and lists group it there. A | |
| 171 | + | team's maintainers can add it to other teams too. Every turn, it is told | |
| 172 | + | who is on its teams, who leads them and who owns what; see | |
| 173 | + | [what agents are told](/guides/people-and-teams/#what-agents-are-told). | |
| 174 | + | Hearing a team's mentions and review requests, the way a person on the | |
| 171 | 175 | team does, comes next. <Soon /> | |
| 172 | 176 | </Aside> | |
| 173 | 177 | ||
| ⋯ | |||
| 282 | 286 | ## Agents know each other | |
| 283 | 287 | ||
| 284 | 288 | Every agent, not only g1t, knows the team: each colleague's name, title, | |
| 285 | − | team, responsibilities and status. When a question belongs to someone | |
| 286 | − | else, it makes one of three moves, always in the open. | |
| 289 | + | team, responsibilities and status. It also knows the people and agents on | |
| 290 | + | its own [teams](/guides/teams/): who leads each, what each person owns, | |
| 291 | + | who reports to whom, who is around now and their local time, and who to | |
| 292 | + | ask when a person is needed. It is told this every turn; see | |
| 293 | + | [what agents are told](/guides/people-and-teams/#what-agents-are-told). | |
| 294 | + | When a question belongs to someone else, it makes one of three moves, | |
| 295 | + | always in the open. | |
| 287 | 296 | ||
| 288 | 297 | | Move | What happens | Example | | |
| 289 | 298 | | --- | --- | --- | | |
| ⋯ | |||
| 573 | 582 | summary of what it shipped, what it is waiting on and what it spent. | |
| 574 | 583 | <Soon /> | |
| 575 | 584 | </Card> | |
| 576 | − | <Card title="Members of teams" icon="users"> | |
| 577 | − | Add an agent to a [team](/guides/teams/) like a person: it gets the | |
| 578 | − | team's channels and mentions, can be asked to review through the team, | |
| 579 | − | and shows on the team's page. <Soon /> | |
| 585 | + | <Card title="Team mentions and reviews" icon="users"> | |
| 586 | + | An agent on a [team](/guides/teams/) hears the team's mentions and | |
| 587 | + | can be asked to review through it, the way a person on the team does. | |
| 588 | + | <Soon /> | |
| 580 | 589 | </Card> | |
| 581 | 590 | <Card title="Create one in chat" icon="wand-sparkles"> | |
| 582 | 591 | Describe the agent you want in a message, and confirm the draft card g1t | |
| 127 | 127 | ||
| 128 | 128 | | | Invite to g1t | Invite to a workspace | | |
| 129 | 129 | | --- | --- | --- | | |
| 130 | − | | Made from | [Settings → Invites](https://g1t.sh/settings/invites), your own | The workspace's **People** page, by its owners | | |
| 130 | + | | Made from | [Settings → Invites](https://g1t.sh/settings/invites), your own | The workspace's **Members and invites** page, by its owners | | |
| 131 | 131 | | What it gives | One new account. It adds them to no workspace: the account gets a workspace of its own | An invitation to join that workspace, which they accept or decline | | |
| 132 | 132 | | Someone without an account | Makes their account with it | Makes their account with it too, while g1t is invite-only, then answers the invitation | | |
| 133 | 133 | | Someone already on g1t | Nothing: they have an account | The invitation, in their notifications and by email | | |
| ⋯ | |||
| 228 | 228 | - **It makes your own account.** Each person who uses it gets a new | |
| 229 | 229 | account, and then makes their own workspace. It does not add you to | |
| 230 | 230 | anyone else's workspace; once you are in, a workspace's owners can add | |
| 231 | − | you from its People page. | |
| 231 | + | you from its **Members and invites** page. | |
| 232 | 232 | - **It may be for some email domains only.** When it is, the email field | |
| 233 | 233 | says which, such as `example.com`, and sign-up takes only an address | |
| 234 | 234 | there. Use your address at that organization; you confirm it like any | |
| ⋯ | |||
| 269 | 269 | are in. | |
| 270 | 270 | ||
| 271 | 271 | To bring someone into a workspace, you do not need an invite to g1t: | |
| 272 | − | invite them from the workspace's People page instead (see | |
| 272 | + | invite them from the workspace's **Members and invites** page, | |
| 273 | + | `g1t.sh/<workspace>/-/members`, instead (see | |
| 273 | 274 | [inviting someone into a workspace](#inviting-someone-into-a-workspace)). | |
| 274 | − | Settings → Invites links to the People pages of the workspaces you own. | |
| 275 | + | Settings → Invites links to the Members and invites pages of the | |
| 276 | + | workspaces you own. | |
| 275 | 277 | ||
| 276 | 278 | Once anyone can sign up for g1t, there are no invites to g1t to make: | |
| 277 | 279 | Settings → Invites keeps only the list of invites you already made, and | |
| 278 | 280 | says that anyone can sign up now and that workspace invitations live on | |
| 279 | − | each workspace's People page. With no invites made, the page is not listed | |
| 281 | + | each workspace's Members and invites page. With no invites made, the page is not listed | |
| 280 | 282 | in your settings or account menu. | |
| 281 | 283 | ||
| 282 | 284 | Each person can have **5** invites out at a time. Pending and used invites | |
| ⋯ | |||
| 291 | 293 | ||
| 292 | 294 | ### Inviting someone into a workspace | |
| 293 | 295 | ||
| 294 | − | An owner can invite someone into a workspace from its People page, by | |
| 296 | + | An owner can invite someone into a workspace from its **Members and | |
| 297 | + | invites** page, `g1t.sh/<workspace>/-/members`, by | |
| 295 | 298 | username or by email address, with the role they join as; see | |
| 296 | 299 | [add people](/guides/workspaces/#add-people). Nobody is added without | |
| 297 | 300 | saying yes: someone on g1t gets a [workspace invitation](#workspace-invitations) | |
| ⋯ | |||
| 314 | 317 | ||
| 315 | 318 | An invitation works for 30 days, the same as an invite. Until it is | |
| 316 | 319 | answered, the workspace's owners see it under **Pending invitations** on | |
| 317 | − | its People page and can revoke it. A workspace on the free plan cannot add | |
| 320 | + | its Members and invites page and can revoke it. A workspace on the free plan cannot add | |
| 318 | 321 | people, so an invitation to one cannot be accepted until it starts the | |
| 319 | 322 | plan. Only you can answer your invitations: an agent's token and a | |
| 320 | 323 | workspace's token cannot. | |
| ⋯ | |||
| 755 | 758 | | SSH keys | Settings → SSH keys | | |
| 756 | 759 | | Applications you signed in to, and signing in with GitHub | Settings → Connected applications, Settings → GitHub | | |
| 757 | 760 | | Letting a device or an application sign in | `g1t.sh/device`, `g1t.sh/oauth/authorize` | | |
| 758 | − | | Deleting a workspace, and giving it to another owner | A workspace's Settings and People | | |
| 761 | + | | Deleting a workspace, and giving it to another owner | A workspace's Settings, and Members and invites | | |
| 759 | 762 | | Payment methods: the billing portal, adding a card, subscribing and buying AI credit | A workspace's Billing | | |
| 760 | 763 | ||
| 761 | 764 | ### Permissions | |
| 537 | 537 | Everyone you share a workspace with can see whether you're here, and what | |
| 538 | 538 | you've said about yourself, wherever your name shows: beside a direct | |
| 539 | 539 | message in the sidebar, on the card over your name, in a channel's member | |
| 540 | − | list, on the workspace's People page and after your name on your messages. | |
| 540 | + | list, in the workspace's [People directory](/guides/people-and-teams/#the-directory) and after your name on your messages. | |
| 541 | 541 | It all moves live; nobody has to reload. | |
| 542 | 542 | ||
| 543 | 543 | | The dot on an avatar | It means | |
| 1 | + | --- | |
| 2 | + | title: People and teams | |
| 3 | + | description: Find anyone in a workspace, person or agent, see what they own and who they report to, and know what agents are told about their teams. | |
| 4 | + | --- | |
| 5 | + | ||
| 6 | + | **People** is where a workspace's people and agents are listed together: | |
| 7 | + | who they are, the teams they are on, what they own and who they report | |
| 8 | + | to. Open it from **People** at the foot of the dock. Its sidebar has: | |
| 9 | + | ||
| 10 | + | | Page | Address | What it is | | |
| 11 | + | | --- | --- | --- | | |
| 12 | + | | **Everyone** | `g1t.sh/<workspace>/-/people` | The [directory](#the-directory) of people and agents. | | |
| 13 | + | | **Teams** | `g1t.sh/<workspace>/-/teams` | The workspace's [teams](/guides/teams/). | | |
| 14 | + | | **Org chart** | `g1t.sh/<workspace>/-/org-chart` | Who reports to whom. See [the org chart](#the-org-chart). | | |
| 15 | + | | **Members and invites** | `g1t.sh/<workspace>/-/members` | Under **Membership**: roles, invitations and leaving. See [members and invites](#members-and-invites). | | |
| 16 | + | ||
| 17 | + | Only members of the workspace can open these pages. | |
| 18 | + | ||
| 19 | + | ## The directory | |
| 20 | + | ||
| 21 | + | The directory, `g1t.sh/<workspace>/-/people`, lists people and agents in | |
| 22 | + | one place, with one search box. | |
| 23 | + | ||
| 24 | + | 1. Open **People**, then **Everyone**. | |
| 25 | + | 2. Type in **Search by name, title, team or what they own**. | |
| 26 | + | 3. Choose **Everyone**, **People** or **Agents** to narrow the list. Each | |
| 27 | + | shows how many it has. | |
| 28 | + | ||
| 29 | + | The search matches: | |
| 30 | + | ||
| 31 | + | | | Matches on | | |
| 32 | + | | --- | --- | | |
| 33 | + | | **A person** | Name, username, title, teams, what they own, location and bio. | | |
| 34 | + | | **An agent** | Name, handle, title, role, responsibilities and teams. | | |
| 35 | + | ||
| 36 | + | The address keeps what you chose, so you can share it: | |
| 37 | + | `?q=billing` for the search, and `?kind=people` or `?kind=agents` for the | |
| 38 | + | switch. | |
| 39 | + | ||
| 40 | + | Each card shows: | |
| 41 | + | ||
| 42 | + | - the person's picture with a dot for whether they are around, or the | |
| 43 | + | agent's picture with a dot for its status; | |
| 44 | + | - their name and title; | |
| 45 | + | - their teams; | |
| 46 | + | - their local time, from the time zone on their | |
| 47 | + | [profile](https://g1t.sh/settings/profile); | |
| 48 | + | - what they own. | |
| 49 | + | ||
| 50 | + | **Org chart** opens [the org chart](#the-org-chart). Owners also see | |
| 51 | + | **Invite people**, which opens [Members and invites](#members-and-invites). | |
| 52 | + | ||
| 53 | + | ## A person's profile | |
| 54 | + | ||
| 55 | + | Choose someone in the directory to open their profile, | |
| 56 | + | `g1t.sh/<workspace>/-/people/<username>`. It shows their title, username, | |
| 57 | + | pronouns, whether they are around and their status, location and local | |
| 58 | + | time, their bio and what they own. Their name, pronouns, bio, location and | |
| 59 | + | time zone come from their account's | |
| 60 | + | [profile settings](/guides/authentication/); their title and what they own | |
| 61 | + | are set for this workspace. | |
| 62 | + | ||
| 63 | + | | Card | What it shows | | |
| 64 | + | | --- | --- | | |
| 65 | + | | **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 | + | | **Agents on their teams** | Every agent on the teams they are on. | | |
| 68 | + | | **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 | + | ||
| 70 | + | **Message** opens your direct message with them in [Chat](/guides/chat/). | |
| 71 | + | ||
| 72 | + | ### Edit a profile | |
| 73 | + | ||
| 74 | + | You can edit your own profile; owners can edit anyone's. | |
| 75 | + | ||
| 76 | + | 1. Open the profile, `g1t.sh/<workspace>/-/people/<username>`. | |
| 77 | + | 2. Choose **Edit profile**. | |
| 78 | + | 3. Change what you need, and save. | |
| 79 | + | ||
| 80 | + | | Field | Who sets it | Limits | | |
| 81 | + | | --- | --- | --- | | |
| 82 | + | | **Title** | The person, or an owner | Their job here, such as *Staff Engineer*. Up to 80 characters. | | |
| 83 | + | | **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 | + | ||
| 86 | + | What you set here is shown in the directory and told to the agents on | |
| 87 | + | their teams (see [what agents are told](#what-agents-are-told)). Each | |
| 88 | + | change is recorded in the workspace's [audit log](/guides/audit-log/) as | |
| 89 | + | `member.profile_edited`. | |
| 90 | + | ||
| 91 | + | When someone leaves the workspace, anyone who reported to them reports to | |
| 92 | + | no one, and any team they led has no lead. | |
| 93 | + | ||
| 94 | + | ## An agent's profile | |
| 95 | + | ||
| 96 | + | Each agent has a People profile too, | |
| 97 | + | `g1t.sh/<workspace>/-/people/agents/<handle>`, with its title, handle and | |
| 98 | + | status. | |
| 99 | + | ||
| 100 | + | | Card | What it shows | | |
| 101 | + | | --- | --- | | |
| 102 | + | | **Responsible for** | Its responsibilities, from its [profile](/guides/agents/#title-team-and-responsibilities). | | |
| 103 | + | | **Teams** | The teams it is on, with **Lead** and **Home team** where they apply. | | |
| 104 | + | | **Who it works with** | The people on its teams, leads first. | | |
| 105 | + | | **Agents on its teams** | The other agents on those teams. | | |
| 106 | + | | **Access** | **Code**: what the person who asks it can read, and only what everyone in the conversation can see. **Storage**: coming. What it spent this month of its monthly budget. | | |
| 107 | + | | **What** *agent* **knows about its teams** | The exact text it is told every turn. See [what agents are told](#what-agents-are-told). | | |
| 108 | + | ||
| 109 | + | **Message** opens your direct message with it. **Sessions and settings** | |
| 110 | + | opens its page in [Agents](/guides/agents/). | |
| 111 | + | ||
| 112 | + | ## Teams of any mix | |
| 113 | + | ||
| 114 | + | A [team](/guides/teams/) can be people and agents together, people only, | |
| 115 | + | or agents only. A team's page says which, with its lead, its channel, its | |
| 116 | + | roles on repositories and its budget for its agents. The **Teams** list | |
| 117 | + | shows a small mark for each, and counts such as | |
| 118 | + | *2 members · 1 agent · 1 repository*. | |
| 119 | + | ||
| 120 | + | | | Where | | |
| 121 | + | | --- | --- | | |
| 122 | + | | Add or remove an agent | The team's **People and agents** tab. See [agents on a team](/guides/teams/#agents-on-a-team). | | |
| 123 | + | | Set the lead, channel and budget | The team's **Settings**. See [lead, channel and budget](/guides/teams/#lead-channel-and-budget). | | |
| 124 | + | | Storage for a team | Coming. | | |
| 125 | + | ||
| 126 | + | ## The org chart | |
| 127 | + | ||
| 128 | + | The org chart, `g1t.sh/<workspace>/-/org-chart`, draws the reporting | |
| 129 | + | lines as a tree: each person, with the people who report to them below. | |
| 130 | + | Beside each person are the agents of the teams they lead. | |
| 131 | + | ||
| 132 | + | Under **Teams no person leads** are the teams led by an agent or by no | |
| 133 | + | one, each with its agents. | |
| 134 | + | ||
| 135 | + | Until someone has a manager, the page says so. Owners set who each person | |
| 136 | + | reports to with **Edit profile** on that person's profile (see | |
| 137 | + | [edit a profile](#edit-a-profile)). | |
| 138 | + | ||
| 139 | + | ## What agents are told | |
| 140 | + | ||
| 141 | + | Every time an agent replies, and at every step of a | |
| 142 | + | [session](/guides/agent-sessions/), it is told about each **visible** team | |
| 143 | + | it is on, under **Your teams**. It is never told about a | |
| 144 | + | [secret team](/guides/teams/#visibility). | |
| 145 | + | ||
| 146 | + | For each team, it is told: | |
| 147 | + | ||
| 148 | + | 1. The team's description, who leads it, its channel and its shared | |
| 149 | + | budget for agents. | |
| 150 | + | 2. Each person on it: name and username, title, whether they are the lead | |
| 151 | + | or a maintainer, what they own, who they report to, how they show right | |
| 152 | + | now, and their local time. | |
| 153 | + | 3. Each agent on it, with *you* for itself. | |
| 154 | + | 4. **When a person is needed**: who to ask. | |
| 155 | + | ||
| 156 | + | How a person shows right now is one of: | |
| 157 | + | ||
| 158 | + | | Shows as | When | | |
| 159 | + | | --- | --- | | |
| 160 | + | | **online** | They have g1t open and are active. | | |
| 161 | + | | **away** | Every g1t tab they have open is idle, or they set themselves away. | | |
| 162 | + | | **focusing, in Do Not Disturb until** *a time their time* | They turned on Do Not Disturb. | | |
| 163 | + | | **offline** | They have no g1t tab open. | | |
| 164 | + | ||
| 165 | + | Their status, if they set one, follows. If g1t can't read who is around, | |
| 166 | + | it says nothing about it: nobody is called offline, and no one is named | |
| 167 | + | under **When a person is needed**. | |
| 168 | + | ||
| 169 | + | **When a person is needed** names the team's lead if they are online and | |
| 170 | + | not in Do Not Disturb; otherwise a maintainer who is; otherwise anyone on | |
| 171 | + | the team who is. When no one can be reached, the agent is told to post in | |
| 172 | + | the team's channel, if it has one, and say they'll see it when they're | |
| 173 | + | back. | |
| 174 | + | ||
| 175 | + | Agents are also told to ask the person who owns something before | |
| 176 | + | guessing, and to hold questions that aren't urgent for someone who is | |
| 177 | + | focusing or away, and say so. Being told about a team doesn't change who | |
| 178 | + | reads an agent's answer: only the members of the conversation do. See | |
| 179 | + | [who is in the conversation](/guides/agents/#who-is-in-the-conversation). | |
| 180 | + | ||
| 181 | + | You can read what an agent is told: | |
| 182 | + | ||
| 183 | + | - on its People profile, under **What** *agent* **knows about its teams**; | |
| 184 | + | - on a team's **People and agents** tab, under **What the agents on** | |
| 185 | + | *team* **know about it**, for that team's part. | |
| 186 | + | ||
| 187 | + | ### An example | |
| 188 | + | ||
| 189 | + | What Margo, an agent on the Billing team, is told: | |
| 190 | + | ||
| 191 | + | ```text | |
| 192 | + | ## Your teams | |
| 193 | + | ||
| 194 | + | From your teams' pages, and how people show right now. Use it to know who leads, who owns what and who to ask or hand work to. It doesn't change who is in this conversation: anyone not listed there still doesn't read what you say here. | |
| 195 | + | ||
| 196 | + | - Ask the person who owns something before guessing. Hold non-urgent questions for someone focusing or away, and say so. | |
| 197 | + | ||
| 198 | + | ### Billing | |
| 199 | + | ||
| 200 | + | 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. | |
| 201 | + | ||
| 202 | + | - Priya Shah (@priya); Engineering Manager, the lead, a maintainer; owns Stripe, Invoices; reports to @dana (online, 14:05 their time) | |
| 203 | + | - Leo Park (@leo); Staff Engineer; owns Refunds; reports to @priya (focusing, in Do Not Disturb until 16:00 their time, 22:05 their time) | |
| 204 | + | - @margo: you | |
| 205 | + | - @sam (Sam), an agent: Support Specialist | |
| 206 | + | ||
| 207 | + | When a person is needed: Priya Shah (@priya), who is online now. | |
| 208 | + | ``` | |
| 209 | + | ||
| 210 | + | ## Members and invites | |
| 211 | + | ||
| 212 | + | Inviting people, their roles, pending invitations, outside collaborators, | |
| 213 | + | the base permission, transferring ownership and leaving a workspace are | |
| 214 | + | on **Members and invites**, `g1t.sh/<workspace>/-/members` (People → | |
| 215 | + | Members and invites). See [add people](/guides/workspaces/#add-people), | |
| 216 | + | [change someone's role](/guides/workspaces/#change-someones-role) and | |
| 217 | + | [the base permission](/guides/access-and-roles/#the-base-permission). | |
| 218 | + | ||
| 219 | + | ## Next | |
| 220 | + | ||
| 221 | + | - [Teams](/guides/teams/): roles on repositories, mentions, review | |
| 222 | + | requests, and a team's lead, channel and budget. | |
| 223 | + | - [Agents](/guides/agents/): hiring agents, and their home team. | |
| 224 | + | - [Agent budgets](/guides/agent-budgets/): how a team's budget stacks | |
| 225 | + | with the others. |
| 68 | 68 | | **Each agent** | Owners, on the agent's **Profile**. A new agent starts with the default set here. | That agent takes no new work until the 1st, or the next day for a daily cap. | | |
| 69 | 69 | | **Each task** | Owners, here: the cap a session starts with. The plan's caps on one run and one issue are under [Billing → Caps](/guides/usage-and-billing/#caps). | The session stops at **Needs approval**, and an owner decides whether it goes on. | | |
| 70 | 70 | ||
| 71 | + | A team can also have a budget for what its agents spend together, set on | |
| 72 | + | the team's own settings and checked after a person's budget. See | |
| 73 | + | [lead, channel and budget](/guides/teams/#lead-channel-and-budget). | |
| 74 | + | ||
| 71 | 75 | Work already running finishes, so spend can go slightly past a budget. | |
| 72 | 76 | ||
| 73 | 77 | ### A person's budget |
| 1 | 1 | --- | |
| 2 | 2 | title: Teams | |
| 3 | − | description: Group a workspace's members into teams, give a team a role on repositories, mention it with @workspace/team, and ask it to review pull requests, with review assignment picking who. | |
| 3 | + | description: Group a workspace's people and agents into teams, give a team a lead, a channel and a budget for its agents, give a team a role on repositories, mention it with @workspace/team, and ask it to review pull requests, with review assignment picking who. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | − | A **team** is a group of a workspace's members. Instead of giving each | |
| 7 | − | person a role on each repository, you give the team a role, and everyone | |
| 8 | − | in it has it. A team is also a name you can use in conversation: mention | |
| 6 | + | A **team** is a group of a workspace's people and agents, in any mix: | |
| 7 | + | people and agents together, people only, or agents only. Instead of | |
| 8 | + | giving each person a role on each repository, you give the team a role, | |
| 9 | + | and everyone in it has it. A team can have a lead, a channel and a | |
| 10 | + | monthly budget for its agents, and every agent on it is told who is on | |
| 11 | + | the team, who leads it and who owns what. A team is also a name you can use in conversation: mention | |
| 9 | 12 | `@acme/backend` in a comment and everyone in it hears of it, or ask it to | |
| 10 | 13 | review a pull request and it decides who looks. | |
| 11 | 14 | ||
| 12 | − | Only members of a workspace can be in its teams. Someone who leaves the | |
| 13 | − | workspace leaves all of its teams with it. | |
| 15 | + | Only members of a workspace, and its own agents, can be in its teams. | |
| 16 | + | Someone who leaves the workspace leaves all of its teams with it. | |
| 17 | + | ||
| 18 | + | For the workspace's directory, profiles and org chart, see | |
| 19 | + | [people and teams](/guides/people-and-teams/). | |
| 14 | 20 | ||
| 15 | 21 | ## Create a team | |
| 16 | 22 | ||
| ⋯ | |||
| 40 | 46 | | Teams in one workspace | Up to 500. | | |
| 41 | 47 | ||
| 42 | 48 | The **Teams** page lists the teams you can see, yours first, then by | |
| 43 | − | name, with a search box that matches names and slugs. A person's teams | |
| 44 | − | also show beside them on the workspace's **People** page. | |
| 49 | + | name, with a search box that matches names and slugs. Each team shows a | |
| 50 | + | small mark for what it is made of, and counts such as | |
| 51 | + | *2 members · 1 agent · 1 repository*. A person's teams also show on | |
| 52 | + | their card in the [People directory](/guides/people-and-teams/#the-directory) | |
| 53 | + | and on their profile. | |
| 45 | 54 | ||
| 46 | 55 | ### Who can create teams | |
| 47 | 56 | ||
| ⋯ | |||
| 86 | 95 | | See a secret team | Its own people and the workspace's owners | | |
| 87 | 96 | | Create a team | Any member. Under a parent: an owner, or a maintainer of the parent | | |
| 88 | 97 | | Add and remove people, make someone a maintainer | Owners, and the team's maintainers | | |
| 98 | + | | Add and remove agents | Owners, and the team's maintainers | | |
| 99 | + | | Set its lead, channel and budget | Owners, and the team's maintainers | | |
| 89 | 100 | | Change its name, slug, description, visibility, notifications and review assignment | Owners, and the team's maintainers | | |
| 90 | 101 | | Move it under another team | Owners, or someone who maintains both teams | | |
| 91 | 102 | | Delete it | Owners, and the team's maintainers | | |
| ⋯ | |||
| 99 | 110 | ||
| 100 | 111 | To manage the people in a team: | |
| 101 | 112 | ||
| 102 | − | 1. Open the team, `g1t.sh/<workspace>/-/teams/<team>`. **Members** is its | |
| 103 | − | first tab. | |
| 113 | + | 1. Open the team, `g1t.sh/<workspace>/-/teams/<team>`. **People and | |
| 114 | + | agents** is its first tab. | |
| 104 | 115 | 2. Add someone by username. They must already be a member of the | |
| 105 | 116 | workspace. | |
| 106 | 117 | 3. Beside a person, choose **Make maintainer** (or **Make member**), or | |
| ⋯ | |||
| 109 | 120 | Turn on **Include the people of child teams** to also list the people of its | |
| 110 | 121 | [child teams](#nesting), each with the child team they are in. | |
| 111 | 122 | ||
| 123 | + | ## Agents on a team | |
| 124 | + | ||
| 125 | + | The **People and agents** tab lists the team's **Agents** under its | |
| 126 | + | people. An agent is on a team in one of two ways: | |
| 127 | + | ||
| 128 | + | | How | Badge | | | |
| 129 | + | | --- | --- | --- | | |
| 130 | + | | **Its home team** | **Home team** | The team its own profile names under **Team** (see [title, team and responsibilities](/guides/agents/#title-team-and-responsibilities)). To take it off, change its profile. | | |
| 131 | + | | **Added** | None | A maintainer or an owner added it from the team's page. An agent can be on any number of teams this way. | | |
| 132 | + | ||
| 133 | + | To add or remove an agent: | |
| 134 | + | ||
| 135 | + | 1. Open the team's **People and agents** tab, | |
| 136 | + | `g1t.sh/<workspace>/-/teams/<team>`. | |
| 137 | + | 2. Under **Agents**, choose one of the workspace's agents in **Add an | |
| 138 | + | agent**, and **Add**. | |
| 139 | + | 3. To take an added agent off, choose **Remove** beside it. An agent | |
| 140 | + | whose home team it is has no **Remove**: change its profile instead. | |
| 141 | + | ||
| 142 | + | An agent on a team works with the access of whoever asks it, not with the | |
| 143 | + | team's roles on repositories. Being on a team tells the agent about it | |
| 144 | + | (see [what agents on the team know](#what-agents-on-the-team-know)), and | |
| 145 | + | its spend counts towards the team's [budget](#lead-channel-and-budget). | |
| 146 | + | ||
| 147 | + | ## Lead, channel and budget | |
| 148 | + | ||
| 149 | + | A team's header shows what it is made of (such as *People only*, *Agents | |
| 150 | + | only*, or *2 people and 1 agent*), **Led by**, its **#channel**, | |
| 151 | + | **Code: N repositories** (its roles on repositories), **Storage** | |
| 152 | + | (coming), and its budget: *$X of $Y this month*, what its agents spent | |
| 153 | + | together against it. | |
| 154 | + | ||
| 155 | + | A maintainer or an owner sets them under the team's **Settings**, **Lead, | |
| 156 | + | channel and budget**: | |
| 157 | + | ||
| 158 | + | | Setting | What it is | | |
| 159 | + | | --- | --- | | |
| 160 | + | | **Lead** | Someone on the team: a person or an agent. Choosing an agent that is on the team only through its home team adds it to the team too. When a person is needed, the team's agents ask the lead first. | | |
| 161 | + | | **Channel** | The team's channel in [Chat](/guides/chat/), by name. Leave it empty for none. | | |
| 162 | + | | **Budget for its agents, a month** | In dollars, such as `150`. Empty or `0` for no team budget. At most $1,000,000. | | |
| 163 | + | | **Storage level** | Coming. | | |
| 164 | + | ||
| 165 | + | Taking the lead off the team, or out of the workspace, leaves the team | |
| 166 | + | with no lead. | |
| 167 | + | ||
| 168 | + | The **budget** caps what the team's agents, added and home team alike, | |
| 169 | + | spend together in a calendar month (UTC). When they reach it, its agents | |
| 170 | + | take no new work until the 1st, and say: | |
| 171 | + | ||
| 172 | + | ```text | |
| 173 | + | Backend's agents have used the team's budget of $150.00 for this month. Someone who manages the team can raise it on its settings. | |
| 174 | + | ``` | |
| 175 | + | ||
| 176 | + | Each agent keeps its own budget too; the first limit reached stops the | |
| 177 | + | work. See [how limits stack](/guides/agent-budgets/#how-limits-stack). | |
| 178 | + | ||
| 179 | + | ## What agents on the team know | |
| 180 | + | ||
| 181 | + | Every agent on a visible team is told about it every time it replies and | |
| 182 | + | at every session step: its description, lead, channel and budget, each | |
| 183 | + | person with their title, what they own, who they report to, whether they | |
| 184 | + | are around and their local time, and the team's other agents. The | |
| 185 | + | **People and agents** tab shows the team's part of it, exactly as the | |
| 186 | + | agents get it, under **What the agents on** *team* **know about it**. | |
| 187 | + | ||
| 188 | + | Agents are never told about secret teams. See | |
| 189 | + | [what agents are told](/guides/people-and-teams/#what-agents-are-told). | |
| 190 | + | ||
| 112 | 191 | ## Nesting | |
| 113 | 192 | ||
| 114 | 193 | A team can have a **parent**. Choose one when you create it, or under the | |
| ⋯ | |||
| 297 | 376 | ## Settings | |
| 298 | 377 | ||
| 299 | 378 | A team's **Settings**, `g1t.sh/<workspace>/-/teams/<team>/settings`, holds | |
| 300 | − | its name, slug, description, visibility, parent, **Notify the team when it is mentioned**, **Code review | |
| 379 | + | its name, slug, description, visibility, parent, **Lead, channel and | |
| 380 | + | budget**, **Notify the team when it is mentioned**, **Code review | |
| 301 | 381 | assignment**, and **Delete team**. | |
| 302 | 382 | ||
| 303 | 383 | Changing the slug changes how the team is mentioned and its address. A | |
| 76 | 76 | waits, and works once the plan is on). | |
| 77 | 77 | ||
| 78 | 78 | Its members can still be given a role on its repositories, and put on its | |
| 79 | − | [teams](/guides/teams/). On the **People** page and a repository's | |
| 79 | + | [teams](/guides/teams/). On the **Members and invites** page and a repository's | |
| 80 | 80 | **Settings → Access**, an owner sees **Start the plan to invite people** | |
| 81 | 81 | with a button to the plan. Through the API and MCP, adding a member, | |
| 82 | 82 | inviting, adding an outside collaborator and accepting are refused with |
| 273 | 273 | same time can also ask each other questions and hand each other work. See | |
| 274 | 274 | [talk to agents](/guides/talking-to-agents/). | |
| 275 | 275 | ||
| 276 | + | An agent on one of the workspace's [teams](/guides/teams/) is told about | |
| 277 | + | its teams at every reply and every session step: who leads each, who is on | |
| 278 | + | it and what they own, who is around now, and who to ask when a person is | |
| 279 | + | needed. It asks the person who owns something before guessing, and holds | |
| 280 | + | non-urgent questions for someone who is focusing or away. See | |
| 281 | + | [what agents are told](/guides/people-and-teams/#what-agents-are-told). | |
| 282 | + | ||
| 276 | 283 | ### Merging automatically | |
| 277 | 284 | ||
| 278 | 285 | A repository can land g1t's pull request by itself once it is |
| 303 | 303 | ||
| 304 | 304 | ### Change someone's role | |
| 305 | 305 | ||
| 306 | − | On **People**, an owner opens the **⋯** menu beside a member: | |
| 306 | + | On **Members and invites**, `g1t.sh/<workspace>/-/members` (People → | |
| 307 | + | Members and invites), an owner opens the **⋯** menu beside a member: | |
| 307 | 308 | ||
| 308 | 309 | 1. **Make owner** or **Make member** changes their role. | |
| 309 | 310 | 2. **Billing manager** and **Security manager** turn each role on or off. | |
| ⋯ | |||
| 321 | 322 | ||
| 322 | 323 | ### Leave a workspace | |
| 323 | 324 | ||
| 324 | − | Anyone can leave a workspace they belong to: at the bottom of **People**, | |
| 325 | − | choose **Leave {workspace}** and confirm. Your roles on its repositories | |
| 325 | + | Anyone can leave a workspace they belong to: at the bottom of **Members and | |
| 326 | + | invites**, choose **Leave {workspace}** and confirm. Your roles on its repositories | |
| 326 | 327 | and your place in its teams go with you at once. The only owner cannot | |
| 327 | 328 | leave. | |
| 328 | 329 | ||
| ⋯ | |||
| 337 | 338 | ||
| 338 | 339 | | | Invite to a workspace | Invite to g1t | | |
| 339 | 340 | | --- | --- | --- | | |
| 340 | − | | Where | The workspace's **People** page, **Invite to** *workspace* | [Settings → Invites](https://g1t.sh/settings/invites) | | |
| 341 | + | | Where | The workspace's **Members and invites** page, **Invite to** *workspace* | [Settings → Invites](https://g1t.sh/settings/invites) | | |
| 341 | 342 | | What they get | An invitation to join the workspace, to accept or decline | One new account, in no workspace but its own | | |
| 342 | 343 | | Without an account | The invitation lets them sign up first, while g1t is invite-only | It lets them sign up | | |
| 343 | 344 | | When | Always | Only while g1t is invite-only | | |
| 344 | 345 | ||
| 345 | 346 | To invite someone to g1t without adding them to your workspace, use | |
| 346 | − | [Settings → Invites](/guides/authentication/#making-invites); the People | |
| 347 | − | page links there while g1t is invite-only. | |
| 347 | + | [Settings → Invites](/guides/authentication/#making-invites); the Members | |
| 348 | + | and invites page links there while g1t is invite-only. | |
| 348 | 349 | ||
| 349 | − | On the workspace's **People**, `g1t.sh/<workspace>/-/people` (in the | |
| 350 | − | sidebar): | |
| 350 | + | On the workspace's **Members and invites**, `g1t.sh/<workspace>/-/members` | |
| 351 | + | (People → Members and invites in the sidebar): | |
| 351 | 352 | ||
| 352 | 353 | 1. Under **Invite to** *workspace name*, type a username, a name or an email address. | |
| 353 | 354 | As you type, people on g1t are offered by username and name, with their | |
| ⋯ | |||
| 396 | 397 | ||
| 397 | 398 | **A free workspace cannot add people.** Until it starts the g1t plan, it | |
| 398 | 399 | cannot add members, send invites, or invite outside collaborators, and an | |
| 399 | − | invite sent before waits until the plan is on. Its members stay. People | |
| 400 | − | shows **Start the plan to invite people** with the button in place of the | |
| 400 | + | invite sent before waits until the plan is on. Its members stay. Members and | |
| 401 | + | invites shows **Start the plan to invite people** with the button in place of the | |
| 401 | 402 | form, and the API and MCP answer `402` (`payment_required`). See | |
| 402 | 403 | [who a free workspace can add](/guides/usage-and-billing/#who-a-free-workspace-can-add). | |
| 403 | 404 | ||
| ⋯ | |||
| 486 | 487 | | **Projects** | `/-/projects` | Everyone | Every project you can see. See [the Projects page](#the-projects-page). | | |
| 487 | 488 | | [**Packages**](/guides/packages/) | `/-/packages` | Everyone | What the workspace publishes. A visitor opens it from **Packages** on the workspace's page. | | |
| 488 | 489 | | [**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>`. | | |
| 489 | − | | **People** | `/-/people` | Members | Who belongs. Owners add and remove people here. | | |
| 490 | + | | [**People**](/guides/people-and-teams/) | `/-/people` | Members | Everyone in the workspace, people and agents, with their titles, teams and what they own. Each has a profile at `/-/people/<username>`, or `/-/people/agents/<handle>` for an agent. | | |
| 491 | + | | [**Org chart**](/guides/people-and-teams/#the-org-chart) | `/-/org-chart` | Members | Who reports to whom, with the agents of the teams each person leads. | | |
| 492 | + | | **Members and invites** | `/-/members` | Members | Who belongs. Owners invite, change roles and remove people here. | | |
| 490 | 493 | | **Insights** | `/-/insights` | Members | Coming soon: how the whole workspace delivers. | | |
| 491 | 494 | | **Settings** | `/-/settings` | Owners | How the workspace is set up and connected (below). | | |
| 492 | 495 | ||
| ⋯ | |||
| 495 | 498 | or a visitor sees the public ones and those shared with them, without the | |
| 496 | 499 | workspace's people, deployments or settings. | |
| 497 | 500 | ||
| 498 | − | Older addresses still work: `/-/members` opens People, and | |
| 499 | 501 | `g1t.sh/<workspace>?tab=projects` (or `repositories`, `packages`, | |
| 500 | − | `teams`, `people`, `insights` or `settings`) opens that page. | |
| 502 | + | `teams`, `people`, `members`, `insights` or `settings`) opens that page. | |
| 501 | 503 | ||
| 502 | 504 | ### The Projects page | |
| 503 | 505 | ||
| ⋯ | |||
| 552 | 554 | | **Agents** | The workspace's agents and their sessions. See [agents](/guides/agents/). | | |
| 553 | 555 | | **Code** | Projects, pull requests and checks. Not shown to a member without [Code access](/guides/agent-access/). | | |
| 554 | 556 | | **Artifacts** | Documents, decks and pages. See [artifacts](/guides/artifacts/). | | |
| 555 | − | | **People** | Everyone in the workspace, and its [teams](/guides/teams/). | | |
| 557 | + | | **People** | Everyone in the workspace, people and agents; its [teams](/guides/teams/); the org chart; and, under **Membership**, **Members and invites**. See [people and teams](/guides/people-and-teams/). | | |
| 556 | 558 | | **Workspace** | Usage, billing, integrations, policies and settings. | | |
| 557 | 559 | ||
| 558 | 560 | A bar on the dock's edge marks the app you are in. Your account is the | |
| ⋯ | |||
| 602 | 604 | extensions are published. An app you can't use shows **Request access**. | |
| 603 | 605 | The built-in apps' own pages are in their sidebars, not in Apps: Code's has | |
| 604 | 606 | **Projects**, **Security** and **Packages**; Agents' has **Templates**, | |
| 605 | − | **Context** and **Memory**; People's has **Teams**; Workspace's has **Usage**, | |
| 607 | + | **Context** and **Memory**; People's has **Everyone**, **Teams**, **Org chart** and | |
| 608 | + | **Members and invites**; Workspace's has **Usage**, | |
| 606 | 609 | **AI Gateway**, **Integrations** and the **Audit log**. | |
| 607 | 610 | ||
| 608 | 611 | A pinned app shows as its mark under the built-in ones, in the order you | |
| ⋯ | |||
| 660 | 663 | | **Actions** | Members | How workflows run in the workspace. | | |
| 661 | 664 | | **Runners** | Owners | The workspace's self-hosted machines, their groups and registration tokens. | | |
| 662 | 665 | ||
| 663 | − | **People** is for every member to see; owners add and remove people there, | |
| 664 | − | set the [base permission](/guides/access-and-roles/#the-base-permission), | |
| 665 | − | and see the **Outside collaborators** tab. Each member's row also shows the | |
| 666 | − | [teams](/guides/teams/) they are in that you can see. | |
| 666 | + | **Members and invites** is for every member to see; owners invite, add and | |
| 667 | + | remove people there, set the | |
| 668 | + | [base permission](/guides/access-and-roles/#the-base-permission), and see | |
| 669 | + | the **Outside collaborators** tab. Each member's row also shows the | |
| 670 | + | [teams](/guides/teams/) they are in that you can see. To find someone, and | |
| 671 | + | what they work on, use the [People directory](/guides/people-and-teams/#the-directory). | |
| 667 | 672 | ||
| 668 | 673 | Below 1024px wide, the sidebar opens over the page from the left, from the | |
| 669 | 674 | header's button, and closes when you open a page. | |
| 64 | 64 | | [Today](/guides/today/) | The front page: what agents finished today, how much was accepted the first time, what is waiting on you, what was spent, and where to start. | <Status is="preview" /> | | |
| 65 | 65 | | [Chat](/guides/chat/) | Channels, direct messages and threads, live. People and agents are members alike. No seats and no history limit. | <Status is="live" /> | | |
| 66 | 66 | | [Notifications](/guides/notifications/) | Everything waiting on you: mentions, reviews, approvals and alerts, with browser push. | <Status is="live" /> | | |
| 67 | − | | [People and teams](/guides/teams/) | Members, roles, nested teams and invites. | <Status is="live" /> | | |
| 68 | − | | Directory and profiles | Everyone, people and agents, in one search, with profiles, reporting lines and teams that include agents. | <Status is="coming" /> | | |
| 67 | + | | [People and teams](/guides/people-and-teams/) | Everyone, people and agents, in one search, with profiles, reporting lines, an org chart and teams that include agents. Members, roles, nested teams and invites. | <Status is="live" /> | | |
| 69 | 68 | | [Artifacts](/guides/artifacts/) | Documents written together, live, by people and agents. Private until you share them. | <Status is="live" /> | | |
| 70 | 69 | | More kinds of artifact | Slides, designs and dashboards that keep the query behind every number. | <Status is="coming" /> | | |
| 71 | 70 | | Scratchpads | Tools, reports and agents anyone can build on a copy of the data, used in production only after review. | <Status is="coming" /> | | |
| ⋯ | |||
| 195 | 194 | /> | |
| 196 | 195 | <LinkCard | |
| 197 | 196 | title="Teams" | |
| 198 | − | description="Group members into teams, give a team a role on repositories, mention it, and have it pick who reviews." | |
| 197 | + | description="Group people and agents into teams, give a team a role on repositories, mention it, and have it pick who reviews." | |
| 199 | 198 | href="/guides/teams/" | |
| 200 | 199 | /> | |
| 201 | 200 | <LinkCard | |
| 201 | + | title="People and teams" | |
| 202 | + | description="Find anyone, person or agent, see what they own and who they report to, and read what agents are told about their teams." | |
| 203 | + | href="/guides/people-and-teams/" | |
| 204 | + | /> | |
| 205 | + | <LinkCard | |
| 202 | 206 | title="Report deployments" | |
| 203 | 207 | description="See every deployment and environment in one place, report deployments from any CI, and require one before a merge." | |
| 204 | 208 | href="/guides/deployments-api/" | |
| 73 | 73 | </p> | |
| 74 | 74 | <p className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs"> | |
| 75 | 75 | {workspace.members > 1 ? ( | |
| 76 | − | <Link to={`/${workspace.slug}/-/people`} className="text-accent underline-offset-4 hover:underline"> | |
| 76 | + | <Link to={`/${workspace.slug}/-/members`} className="text-accent underline-offset-4 hover:underline"> | |
| 77 | 77 | Make someone else an owner | |
| 78 | 78 | </Link> | |
| 79 | 79 | ) : null} |
| 1 | + | /** | |
| 2 | + | * Pieces of People's pages: a person's face with their presence, an | |
| 3 | + | * agent's with its status, someone's local time, what a team is made of, | |
| 4 | + | * and a label for what is coming. | |
| 5 | + | */ | |
| 6 | + | import { useEffect, useState } from "react"; | |
| 7 | + | import { Link } from "react-router"; | |
| 8 | + | ||
| 9 | + | import { type DirectoryPerson, type TeamKind, teamKind } from "@g1t/contracts"; | |
| 10 | + | ||
| 11 | + | import { AgentFace } from "./agents-mode"; | |
| 12 | + | import { StatusDot } from "./chat/marks"; | |
| 13 | + | import { WithPresence } from "./presence"; | |
| 14 | + | import { Avatar } from "./ui"; | |
| 15 | + | import { Badge } from "./ui/badge"; | |
| 16 | + | import { Hint } from "./ui/hint"; | |
| 17 | + | import { cn } from "../lib/cn"; | |
| 18 | + | import { type PeopleAgent, agentPath, personName, personPath } from "../lib/people"; | |
| 19 | + | import { localTime } from "../lib/time-zone"; | |
| 20 | + | ||
| 21 | + | /** A person's avatar, with their presence in its corner. */ | |
| 22 | + | export function PersonFace({ person, size = 28, ring }: { person: Pick<DirectoryPerson, "user_id" | "username" | "avatar">; size?: number; ring?: string }) { | |
| 23 | + | return ( | |
| 24 | + | <WithPresence person={{ id: person.user_id, username: person.username }} size={size} ring={ring}> | |
| 25 | + | <Avatar name={person.username} image={person.avatar} size={size} /> | |
| 26 | + | </WithPresence> | |
| 27 | + | ); | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /** An agent's face, with its status in its corner. */ | |
| 31 | + | export function AgentPersonFace({ agent, size = 28, ring = "var(--color-bg)" }: { agent: PeopleAgent; size?: number; ring?: string }) { | |
| 32 | + | return ( | |
| 33 | + | <span className="relative inline-flex shrink-0"> | |
| 34 | + | <AgentFace agent={agent} size={size} /> | |
| 35 | + | <span className="absolute -right-0.5 -bottom-0.5 inline-flex rounded-full" style={{ boxShadow: `0 0 0 2px ${ring}` }}> | |
| 36 | + | <StatusDot status={agent.status} className="block" /> | |
| 37 | + | </span> | |
| 38 | + | </span> | |
| 39 | + | ); | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /** | |
| 43 | + | * Someone's local time, worked out in the browser (the server's clock is | |
| 44 | + | * not theirs), and kept current. Nothing when they gave no time zone. | |
| 45 | + | */ | |
| 46 | + | export function LocalTime({ zone, className, suffix = "local" }: { zone: string | null; className?: string; suffix?: string }) { | |
| 47 | + | const [now, setNow] = useState<number | null>(null); | |
| 48 | + | useEffect(() => { | |
| 49 | + | setNow(Date.now()); | |
| 50 | + | const timer = setInterval(() => setNow(Date.now()), 30_000); | |
| 51 | + | return () => clearInterval(timer); | |
| 52 | + | }, []); | |
| 53 | + | if (!zone || now == null) return null; | |
| 54 | + | const time = localTime(zone, now); | |
| 55 | + | if (!time) return null; | |
| 56 | + | return ( | |
| 57 | + | <span className={className}> | |
| 58 | + | {time} {suffix} | |
| 59 | + | </span> | |
| 60 | + | ); | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | const KIND_MARK: Record<TeamKind, string> = { | |
| 64 | + | mixed: "bg-[linear-gradient(135deg,var(--color-success)_50%,var(--color-accent)_50%)]", | |
| 65 | + | people: "bg-success", | |
| 66 | + | agents: "bg-accent", | |
| 67 | + | empty: "bg-line-strong", | |
| 68 | + | }; | |
| 69 | + | ||
| 70 | + | const KIND_WORDS: Record<TeamKind, string> = { | |
| 71 | + | mixed: "People and agents", | |
| 72 | + | people: "People only", | |
| 73 | + | agents: "Agents only", | |
| 74 | + | empty: "No one yet", | |
| 75 | + | }; | |
| 76 | + | ||
| 77 | + | /** A small square that says what a team is made of: people, agents, or both. */ | |
| 78 | + | export function TeamKindMark({ people, agents, className }: { people: number; agents: number; className?: string }) { | |
| 79 | + | const kind = teamKind(people, agents); | |
| 80 | + | return ( | |
| 81 | + | <Hint label={KIND_WORDS[kind]}> | |
| 82 | + | <span tabIndex={0} className={cn("inline-block size-2.5 shrink-0 rounded-[3px]", KIND_MARK[kind], className)}> | |
| 83 | + | <span className="sr-only">{KIND_WORDS[kind]}</span> | |
| 84 | + | </span> | |
| 85 | + | </Hint> | |
| 86 | + | ); | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /** Something that is planned and not here yet. */ | |
| 90 | + | export function Coming({ children = "Coming" }: { children?: string }) { | |
| 91 | + | return <Badge className="border-dashed">{children}</Badge>; | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | /** A person's name, linked to their profile. */ | |
| 95 | + | export function PersonLink({ workspace, person, className }: { workspace: string; person: Pick<DirectoryPerson, "username" | "name" | "display_username">; className?: string }) { | |
| 96 | + | return ( | |
| 97 | + | <Link to={personPath(workspace, person.username)} prefetch="intent" className={cn("hover:text-accent", className)}> | |
| 98 | + | {personName(person)} | |
| 99 | + | </Link> | |
| 100 | + | ); | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | /** An agent's name, linked to its People profile. */ | |
| 104 | + | export function AgentLink({ workspace, agent, className }: { workspace: string; agent: Pick<PeopleAgent, "handle" | "display_name">; className?: string }) { | |
| 105 | + | return ( | |
| 106 | + | <Link to={agentPath(workspace, agent.handle)} prefetch="intent" className={cn("hover:text-accent", className)}> | |
| 107 | + | {agent.display_name} | |
| 108 | + | </Link> | |
| 109 | + | ); | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /** The small "agent" label after an agent's name. */ | |
| 113 | + | export function AgentTag() { | |
| 114 | + | return <Badge tone="accent">Agent</Badge>; | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | /** What the agent is told, as it reads: headings, lines and lists. */ | |
| 118 | + | export function ToldText({ text }: { text: string }) { | |
| 119 | + | const lines = text.split("\n"); | |
| 120 | + | return ( | |
| 121 | + | <div className="space-y-1.5 text-sm text-fg-soft"> | |
| 122 | + | {lines.map((line, index) => { | |
| 123 | + | if (line.startsWith("## ")) return null; | |
| 124 | + | if (line.startsWith("### ")) { | |
| 125 | + | return ( | |
| 126 | + | <h3 key={index} className="pt-2 font-medium text-fg first:pt-0"> | |
| 127 | + | {line.slice(4)} | |
| 128 | + | </h3> | |
| 129 | + | ); | |
| 130 | + | } | |
| 131 | + | if (line.startsWith("- ")) { | |
| 132 | + | return ( | |
| 133 | + | <p key={index} className="relative pl-4 before:absolute before:left-1 before:text-faint before:content-['•']"> | |
| 134 | + | {line.slice(2)} | |
| 135 | + | </p> | |
| 136 | + | ); | |
| 137 | + | } | |
| 138 | + | if (!line.trim()) return null; | |
| 139 | + | return <p key={index}>{line}</p>; | |
| 140 | + | })} | |
| 141 | + | </div> | |
| 142 | + | ); | |
| 143 | + | } |
| 1 | − | import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Bot, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Coins, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, KanbanSquare, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogOut, Mail, Network, Package, PanelLeft, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, Shapes, ShieldCheck, Scale, Smile, Sparkles, Store, Sun, Ticket, TrendingUp, UserRoundKey, Users, UsersRound, Webhook, X, ArrowLeftRight } from "lucide-react"; | |
| 1 | + | import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Bot, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Coins, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, KanbanSquare, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogOut, Mail, Network, Package, PanelLeft, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, Shapes, ShieldCheck, Scale, Smile, Sparkles, Store, Sun, Ticket, TrendingUp, UserPlus, UserRoundKey, Users, UsersRound, Webhook, X, ArrowLeftRight } from "lucide-react"; | |
| 2 | 2 | import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 1265 | 1265 | ); | |
| 1266 | 1266 | } | |
| 1267 | 1267 | ||
| 1268 | − | /** People's sidebar: everyone in the workspace, and its teams. */ | |
| 1268 | + | /** | |
| 1269 | + | * People's sidebar: everyone in the workspace, people and agents; its | |
| 1270 | + | * teams; the org chart; and, to manage who belongs, members and invites. | |
| 1271 | + | */ | |
| 1269 | 1272 | function PeopleSidebar({ slug }: { slug: string }) { | |
| 1270 | 1273 | const { pathname } = useLocation(); | |
| 1271 | 1274 | const going = useNavigation().location?.pathname; | |
| ⋯ | |||
| 1282 | 1285 | <SidebarLink to={`/${slug}/-/teams`} icon={<UsersRound size={15} />} current={at("teams")}> | |
| 1283 | 1286 | Teams | |
| 1284 | 1287 | </SidebarLink> | |
| 1288 | + | <SidebarLink to={`/${slug}/-/org-chart`} icon={<Network size={15} />} current={at("org-chart")}> | |
| 1289 | + | Org chart | |
| 1290 | + | </SidebarLink> | |
| 1285 | 1291 | </div> | |
| 1292 | + | <SidebarGroup title="Membership" className="mt-3"> | |
| 1293 | + | <SidebarLink to={`/${slug}/-/members`} icon={<UserPlus size={15} />} current={at("members")}> | |
| 1294 | + | Members and invites | |
| 1295 | + | </SidebarLink> | |
| 1296 | + | </SidebarGroup> | |
| 1286 | 1297 | </nav> | |
| 1287 | 1298 | </div> | |
| 1288 | 1299 | ); | |
| ⋯ | |||
| 1379 | 1390 | secrets: "Secrets and variables", | |
| 1380 | 1391 | settings: "Settings", | |
| 1381 | 1392 | people: "People", | |
| 1393 | + | "org-chart": "Org chart", | |
| 1394 | + | members: "Members and invites", | |
| 1382 | 1395 | projects: "Projects", | |
| 1383 | 1396 | teams: "Teams", | |
| 1384 | 1397 | tokens: "Access tokens", | |
| 6 | 6 | ||
| 7 | 7 | import { type Team, teamHandle } from "@g1t/contracts"; | |
| 8 | 8 | ||
| 9 | + | import type { PeopleAgent } from "../lib/people"; | |
| 9 | 10 | import { teamCounts, teamPath } from "../lib/teams"; | |
| 10 | 11 | import { Badge } from "./ui/badge"; | |
| 12 | + | import { TeamKindMark } from "./people"; | |
| 11 | 13 | ||
| 12 | − | /** What each page of a team gets from the team's layout. */ | |
| 13 | − | export type TeamContext = { team: Team }; | |
| 14 | + | /** | |
| 15 | + | * What each page of a team gets from the team's layout: the team, the | |
| 16 | + | * agents on it (added, then those whose home team it is), and which of | |
| 17 | + | * them were added (the others are on it through their own profile). | |
| 18 | + | */ | |
| 19 | + | export type TeamContext = { team: Team; agents: PeopleAgent[]; added: string[] }; | |
| 14 | 20 | ||
| 15 | 21 | /** The team whose page this is. */ | |
| 16 | 22 | export function useTeam(): Team { | |
| 17 | 23 | return useOutletContext<TeamContext>().team; | |
| 18 | 24 | } | |
| 19 | 25 | ||
| 26 | + | /** The agents on the team whose page this is, and which were added to it. */ | |
| 27 | + | export function useTeamAgents(): { agents: PeopleAgent[]; added: string[] } { | |
| 28 | + | const { agents, added } = useOutletContext<TeamContext>(); | |
| 29 | + | return { agents, added }; | |
| 30 | + | } | |
| 31 | + | ||
| 20 | 32 | /** Secret, and the viewer's place in the team. */ | |
| 21 | 33 | export function TeamBadges({ team }: { team: Pick<Team, "visibility" | "viewer_role"> }) { | |
| 22 | 34 | return ( | |
| ⋯ | |||
| 34 | 46 | } | |
| 35 | 47 | ||
| 36 | 48 | /** One team in a list: its name, handle, what it is for, and how big it is. */ | |
| 37 | − | export function TeamRow({ team, children }: { team: Team; children?: React.ReactNode }) { | |
| 49 | + | export function TeamRow({ team, agents, children }: { team: Team; agents?: number; children?: React.ReactNode }) { | |
| 50 | + | const agentsHere = agents ?? team.agents_count ?? 0; | |
| 38 | 51 | return ( | |
| 39 | 52 | <li className="flex flex-wrap items-start gap-3 px-4 py-3 transition-colors hover:bg-surface/60 sm:flex-nowrap"> | |
| 40 | 53 | <span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line"> | |
| ⋯ | |||
| 42 | 55 | </span> | |
| 43 | 56 | <div className="min-w-0 grow basis-48"> | |
| 44 | 57 | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> | |
| 58 | + | <TeamKindMark people={team.members_count} agents={agentsHere} /> | |
| 45 | 59 | <Link to={teamPath(team.workspace, team.slug)} prefetch="intent" className="font-medium hover:underline"> | |
| 46 | 60 | {team.name} | |
| 47 | 61 | </Link> | |
| ⋯ | |||
| 50 | 64 | </div> | |
| 51 | 65 | {team.description && <p className="mt-0.5 line-clamp-2 text-sm text-muted">{team.description}</p>} | |
| 52 | 66 | <p className="mt-1 text-xs text-faint"> | |
| 53 | − | {teamCounts(team)} | |
| 67 | + | {teamCounts(team, agentsHere)} | |
| 54 | 68 | {team.parent && ( | |
| 55 | 69 | <> | |
| 56 | 70 | {" · in "} | |
| 289 | 289 | "flagon-io", | |
| 290 | 290 | ), | |
| 291 | 291 | [ | |
| 292 | − | { slug: "flagon-io", name: "Flagon, Inc.", to: "/flagon-io/-/people" }, | |
| 293 | − | { slug: "side", name: "side", to: "/side/-/people" }, | |
| 292 | + | { slug: "flagon-io", name: "Flagon, Inc.", to: "/flagon-io/-/members" }, | |
| 293 | + | { slug: "side", name: "side", to: "/side/-/members" }, | |
| 294 | 294 | ], | |
| 295 | 295 | ); | |
| 296 | 296 | }); |
| 346 | 346 | const here = current?.trim().toLowerCase() || null; | |
| 347 | 347 | return memberships | |
| 348 | 348 | .filter((m) => m.role === "owner") | |
| 349 | − | .map((m) => ({ slug: m.slug.toLowerCase(), name: m.name?.trim() || m.slug, to: `/${m.slug.toLowerCase()}/-/people` })) | |
| 349 | + | .map((m) => ({ slug: m.slug.toLowerCase(), name: m.name?.trim() || m.slug, to: `/${m.slug.toLowerCase()}/-/members` })) | |
| 350 | 350 | .sort((a, b) => Number(b.slug === here) - Number(a.slug === here)); | |
| 351 | 351 | } | |
| 352 | 352 |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import type { DirectoryPerson, DirectoryTeam, PeopleDirectory } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import { | |
| 7 | + | type PeopleAgent, | |
| 8 | + | agentIdsOn, | |
| 9 | + | budgetFromText, | |
| 10 | + | codeAccessWords, | |
| 11 | + | directoryEntries, | |
| 12 | + | managerChoices, | |
| 13 | + | orgChart, | |
| 14 | + | ownsFromText, | |
| 15 | + | teamBlock, | |
| 16 | + | teamsOfAgent, | |
| 17 | + | } from "./people.ts"; | |
| 18 | + | ||
| 19 | + | const person = (username: string, extra: Partial<DirectoryPerson> = {}): DirectoryPerson => ({ | |
| 20 | + | user_id: `usr_${username}`, | |
| 21 | + | username, | |
| 22 | + | name: null, | |
| 23 | + | avatar: null, | |
| 24 | + | bio: null, | |
| 25 | + | location: null, | |
| 26 | + | pronouns: null, | |
| 27 | + | timezone: null, | |
| 28 | + | role: "member", | |
| 29 | + | title: null, | |
| 30 | + | manager: null, | |
| 31 | + | owns: [], | |
| 32 | + | joined_at: "2026-10-01T00:00:00Z", | |
| 33 | + | ...extra, | |
| 34 | + | }); | |
| 35 | + | ||
| 36 | + | const team = (slug: string, name: string, extra: Partial<DirectoryTeam> = {}): DirectoryTeam => ({ | |
| 37 | + | slug, | |
| 38 | + | name, | |
| 39 | + | description: null, | |
| 40 | + | visibility: "visible", | |
| 41 | + | parent: null, | |
| 42 | + | lead: null, | |
| 43 | + | channel: null, | |
| 44 | + | budget_micros: null, | |
| 45 | + | people: [], | |
| 46 | + | agent_ids: [], | |
| 47 | + | repos_count: 0, | |
| 48 | + | ...extra, | |
| 49 | + | }); | |
| 50 | + | ||
| 51 | + | const agent = (id: string, handle: string, extra: Partial<PeopleAgent> = {}): PeopleAgent => ({ | |
| 52 | + | id, | |
| 53 | + | handle, | |
| 54 | + | display_name: handle[0]!.toUpperCase() + handle.slice(1), | |
| 55 | + | avatar: null, | |
| 56 | + | avatar_seed: handle, | |
| 57 | + | title: "", | |
| 58 | + | role: "", | |
| 59 | + | team: null, | |
| 60 | + | department: "", | |
| 61 | + | status: "idle", | |
| 62 | + | responsibilities: [], | |
| 63 | + | builtin: false, | |
| 64 | + | spent_month_micros: 0, | |
| 65 | + | ...extra, | |
| 66 | + | }); | |
| 67 | + | ||
| 68 | + | const directory: PeopleDirectory = { | |
| 69 | + | people: [ | |
| 70 | + | person("chase", { name: "Chase Pierce", title: "Founder", role: "owner" }), | |
| 71 | + | person("sofia", { name: "Sofia Reyes", title: "Head of Sales", manager: "chase", owns: ["the forecast"] }), | |
| 72 | + | person("jordan", { name: "Jordan Lee", title: "Account Executive", manager: "sofia", owns: ["Halcyon", "Bluebird"] }), | |
| 73 | + | person("kai", { name: "Kai Nakamura", title: "Product Designer", manager: "chase" }), | |
| 74 | + | ], | |
| 75 | + | teams: [ | |
| 76 | + | team("sales", "Sales", { | |
| 77 | + | lead: { kind: "user", username: "sofia", name: "Sofia Reyes", avatar: null }, | |
| 78 | + | people: [ | |
| 79 | + | { username: "sofia", role: "maintainer" }, | |
| 80 | + | { username: "jordan", role: "member" }, | |
| 81 | + | ], | |
| 82 | + | agent_ids: ["agt_david"], | |
| 83 | + | repos_count: 0, | |
| 84 | + | }), | |
| 85 | + | team("design", "Design", { people: [{ username: "kai", role: "maintainer" }], repos_count: 2 }), | |
| 86 | + | team("night-shift", "Night shift", { lead: { kind: "agent", agent_id: "agt_atlas" }, agent_ids: ["agt_atlas"] }), | |
| 87 | + | ], | |
| 88 | + | base_permission: "read", | |
| 89 | + | can_manage: true, | |
| 90 | + | }; | |
| 91 | + | ||
| 92 | + | const agents = [ | |
| 93 | + | agent("agt_david", "david", { title: "CRM keeper", team: "sales" }), | |
| 94 | + | agent("agt_atlas", "atlas", { title: "Night watch" }), | |
| 95 | + | agent("agt_sentinel", "sentinel", { title: "Error watch", team: "night-shift", responsibilities: ["Watches errors"] }), | |
| 96 | + | ]; | |
| 97 | + | ||
| 98 | + | test("an agent is on the teams it was added to and its home team", () => { | |
| 99 | + | assert.deepEqual(agentIdsOn(directory.teams[2]!, agents), ["agt_atlas", "agt_sentinel"]); | |
| 100 | + | assert.deepEqual( | |
| 101 | + | teamsOfAgent(directory.teams, agents[2]!).map((t) => t.slug), | |
| 102 | + | ["night-shift"], | |
| 103 | + | ); | |
| 104 | + | }); | |
| 105 | + | ||
| 106 | + | test("the directory finds people and agents in one search", () => { | |
| 107 | + | const names = (query: string, kind: "everyone" | "people" | "agents" = "everyone") => | |
| 108 | + | directoryEntries(directory, agents, query, kind).map((e) => (e.kind === "person" ? e.person.username : `@${e.agent.handle}`)); | |
| 109 | + | assert.deepEqual(names("sales"), ["sofia", "jordan", "@david"]); | |
| 110 | + | assert.deepEqual(names("halcyon"), ["jordan"]); | |
| 111 | + | assert.deepEqual(names("errors"), ["@sentinel"]); | |
| 112 | + | assert.deepEqual(names("", "agents"), ["@david", "@atlas", "@sentinel"]); | |
| 113 | + | assert.deepEqual(names("night"), ["@atlas", "@sentinel"]); | |
| 114 | + | assert.equal(names("nobody here").length, 0); | |
| 115 | + | }); | |
| 116 | + | ||
| 117 | + | test("the org chart follows reporting lines, with each team's agents beside its lead", () => { | |
| 118 | + | const chart = orgChart(directory, agents); | |
| 119 | + | assert.deepEqual(chart.roots.map((n) => n.person.username), ["chase"]); | |
| 120 | + | const chase = chart.roots[0]!; | |
| 121 | + | assert.deepEqual(chase.reports.map((n) => n.person.username), ["sofia", "kai"]); | |
| 122 | + | const sofia = chase.reports[0]!; | |
| 123 | + | assert.deepEqual(sofia.agents.map((a) => a.handle), ["david"]); | |
| 124 | + | assert.deepEqual(sofia.reports.map((n) => n.person.username), ["jordan"]); | |
| 125 | + | // A team an agent leads sits apart, with its agents. | |
| 126 | + | assert.deepEqual(chart.unled.map((u) => [u.team.slug, u.agents.map((a) => a.handle)]), [["night-shift", ["atlas", "sentinel"]]]); | |
| 127 | + | }); | |
| 128 | + | ||
| 129 | + | test("no one goes missing from the org chart, even in a loop", () => { | |
| 130 | + | const looped: PeopleDirectory = { ...directory, people: [person("a", { manager: "b" }), person("b", { manager: "a" })] }; | |
| 131 | + | const chart = orgChart(looped, []); | |
| 132 | + | const all = (nodes: typeof chart.roots): string[] => nodes.flatMap((n) => [n.person.username, ...all(n.reports)]); | |
| 133 | + | assert.deepEqual(all(chart.roots).sort(), ["a", "b"]); | |
| 134 | + | }); | |
| 135 | + | ||
| 136 | + | test("someone can report to anyone but themselves and the people under them", () => { | |
| 137 | + | assert.deepEqual(managerChoices(directory.people, "sofia").map((p) => p.username), ["chase", "kai"]); | |
| 138 | + | assert.deepEqual(managerChoices(directory.people, "jordan").map((p) => p.username), ["chase", "sofia", "kai"]); | |
| 139 | + | }); | |
| 140 | + | ||
| 141 | + | test("forms read plainly", () => { | |
| 142 | + | assert.deepEqual(ownsFromText("storefront\n the release process ,, billing"), ["storefront", "the release process", "billing"]); | |
| 143 | + | assert.equal(budgetFromText(""), 0); | |
| 144 | + | assert.equal(budgetFromText("$150"), 150_000_000); | |
| 145 | + | assert.equal(budgetFromText("1,250.50"), 1_250_500_000); | |
| 146 | + | assert.equal(budgetFromText("lots"), null); | |
| 147 | + | assert.equal(budgetFromText("-5"), null); | |
| 148 | + | }); | |
| 149 | + | ||
| 150 | + | test("access to Code says where it comes from", () => { | |
| 151 | + | assert.equal(codeAccessWords({ role: "owner" }, "read", []).summary, "Admin on every repository, as an owner"); | |
| 152 | + | const kai = codeAccessWords({ role: "member" }, "read", [directory.teams[1]!]); | |
| 153 | + | assert.equal(kai.summary, "Read on every repository, the workspace's base permission"); | |
| 154 | + | assert.deepEqual(kai.through, ["Roles on 2 repositories through Design"]); | |
| 155 | + | assert.equal(codeAccessWords({ role: "member" }, "none", []).summary, "Only repositories they're given a role on"); | |
| 156 | + | }); | |
| 157 | + | ||
| 158 | + | test("a team's part of what an agent is told", () => { | |
| 159 | + | const told = "## Your teams\n\nIntro.\n\n### Sales\n\nLed by Sofia.\n- @sofia\n\n### Support\n\n- @dev"; | |
| 160 | + | assert.equal(teamBlock(told, "Sales"), "### Sales\n\nLed by Sofia.\n- @sofia"); | |
| 161 | + | assert.equal(teamBlock(told, "Support"), "### Support\n\n- @dev"); | |
| 162 | + | assert.equal(teamBlock(told, "Design"), null); | |
| 163 | + | assert.equal(teamBlock(null, "Sales"), null); | |
| 164 | + | }); |
| 1 | + | /** | |
| 2 | + | * People on the site: the directory (people and agents in one search), | |
| 3 | + | * profiles, teams of any mix, and the org chart. Types only from the | |
| 4 | + | * contracts, so this file runs under `node --test`. | |
| 5 | + | */ | |
| 6 | + | import type { DirectoryPerson, DirectoryTeam, PeopleDirectory, TeamLead, WorkspaceAgent } from "@g1t/contracts"; | |
| 7 | + | ||
| 8 | + | /** An agent as People's pages need it. */ | |
| 9 | + | export type PeopleAgent = Pick< | |
| 10 | + | WorkspaceAgent, | |
| 11 | + | "id" | "handle" | "display_name" | "avatar" | "avatar_seed" | "title" | "role" | "team" | "department" | "status" | "responsibilities" | "builtin" | "spent_month_micros" | |
| 12 | + | >; | |
| 13 | + | ||
| 14 | + | /** Just what People's pages read of an agent, so loaders send no more. */ | |
| 15 | + | export function peopleAgent(agent: PeopleAgent): PeopleAgent { | |
| 16 | + | const { id, handle, display_name, avatar, avatar_seed, title, role, team, department, status, responsibilities, builtin, spent_month_micros } = agent; | |
| 17 | + | return { id, handle, display_name, avatar, avatar_seed, title, role, team, department, status, responsibilities, builtin, spent_month_micros }; | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | /** Where a person's profile is. */ | |
| 21 | + | export function personPath(workspace: string, username: string): string { | |
| 22 | + | return `/${workspace}/-/people/${username}`; | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | /** Where an agent's People profile is. */ | |
| 26 | + | export function agentPath(workspace: string, handle: string): string { | |
| 27 | + | return `/${workspace}/-/people/agents/${handle}`; | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /** The agents on a team, by id: those added to it, then those whose home team it is. */ | |
| 31 | + | export function agentIdsOn(team: Pick<DirectoryTeam, "slug" | "agent_ids">, agents: readonly Pick<PeopleAgent, "id" | "team">[]): string[] { | |
| 32 | + | const ids = [...team.agent_ids]; | |
| 33 | + | for (const agent of agents) if (agent.team === team.slug && !ids.includes(agent.id)) ids.push(agent.id); | |
| 34 | + | return ids; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /** The agents on a team, in order, as found among `agents`. */ | |
| 38 | + | export function agentsOn<A extends Pick<PeopleAgent, "id" | "team">>(team: Pick<DirectoryTeam, "slug" | "agent_ids">, agents: readonly A[]): A[] { | |
| 39 | + | const byId = new Map(agents.map((agent) => [agent.id, agent])); | |
| 40 | + | return agentIdsOn(team, agents) | |
| 41 | + | .map((id) => byId.get(id)) | |
| 42 | + | .filter((agent): agent is A => !!agent); | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | /** The teams a person is on. */ | |
| 46 | + | export function teamsOfPerson<T extends Pick<DirectoryTeam, "people">>(teams: readonly T[], username: string): T[] { | |
| 47 | + | return teams.filter((team) => team.people.some((person) => person.username === username)); | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /** The teams an agent is on: added to, or its home team. */ | |
| 51 | + | export function teamsOfAgent<T extends Pick<DirectoryTeam, "slug" | "agent_ids">>(teams: readonly T[], agent: Pick<PeopleAgent, "id" | "team">): T[] { | |
| 52 | + | return teams.filter((team) => team.agent_ids.includes(agent.id) || team.slug === agent.team); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /** Whether `lead` is this person or this agent. */ | |
| 56 | + | export function leads(lead: TeamLead | null, who: { username: string } | { id: string }): boolean { | |
| 57 | + | if (!lead) return false; | |
| 58 | + | if ("username" in who) return lead.kind === "user" && lead.username === who.username; | |
| 59 | + | return lead.kind === "agent" && lead.agent_id === who.id; | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /** A person's name as pages show it. */ | |
| 63 | + | export function personName(person: Pick<DirectoryPerson, "username" | "name" | "display_username">): string { | |
| 64 | + | return person.name?.trim() || person.display_username || person.username; | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /** Whether every word of `query` is somewhere in `fields`. */ | |
| 68 | + | export function matches(query: string, fields: readonly (string | null | undefined)[]): boolean { | |
| 69 | + | const words = query.toLowerCase().split(/\s+/).filter(Boolean); | |
| 70 | + | if (!words.length) return true; | |
| 71 | + | const text = fields.filter(Boolean).join(" ").toLowerCase(); | |
| 72 | + | return words.every((word) => text.includes(word)); | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | export type DirectoryKind = "everyone" | "people" | "agents"; | |
| 76 | + | ||
| 77 | + | export function directoryKind(value: string | null): DirectoryKind { | |
| 78 | + | return value === "people" || value === "agents" ? value : "everyone"; | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /** One card in the directory: a person or an agent. */ | |
| 82 | + | export type DirectoryEntry = | |
| 83 | + | | { kind: "person"; person: DirectoryPerson; teams: DirectoryTeam[] } | |
| 84 | + | | { kind: "agent"; agent: PeopleAgent; teams: DirectoryTeam[] }; | |
| 85 | + | ||
| 86 | + | /** | |
| 87 | + | * Everyone matching `query`, people first by name, then agents (the | |
| 88 | + | * orchestrator first, then by name). People match on their name, title, | |
| 89 | + | * teams, what they own, where they are and their bio; agents on their | |
| 90 | + | * name, handle, title, role, teams and responsibilities. | |
| 91 | + | */ | |
| 92 | + | export function directoryEntries(directory: PeopleDirectory, agents: readonly PeopleAgent[], query: string, kind: DirectoryKind): DirectoryEntry[] { | |
| 93 | + | const out: DirectoryEntry[] = []; | |
| 94 | + | if (kind !== "agents") { | |
| 95 | + | for (const person of directory.people) { | |
| 96 | + | const teams = teamsOfPerson(directory.teams, person.username); | |
| 97 | + | const fields = [person.username, person.name, person.title, person.location, person.bio, ...person.owns, ...teams.map((team) => team.name), person.role === "owner" ? "owner" : null]; | |
| 98 | + | if (matches(query, fields)) out.push({ kind: "person", person, teams }); | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | if (kind !== "people") { | |
| 102 | + | for (const agent of agents) { | |
| 103 | + | const teams = teamsOfAgent(directory.teams, agent); | |
| 104 | + | const fields = [agent.handle, agent.display_name, agent.title, agent.role, agent.department, ...agent.responsibilities, ...teams.map((team) => team.name), "agent"]; | |
| 105 | + | if (matches(query, fields)) out.push({ kind: "agent", agent, teams }); | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | return out; | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /** One person in the org chart, with who reports to them and the agents of the teams they lead. */ | |
| 112 | + | export type OrgNode = { person: DirectoryPerson; agents: PeopleAgent[]; leads: DirectoryTeam[]; reports: OrgNode[] }; | |
| 113 | + | ||
| 114 | + | export type OrgChart = { | |
| 115 | + | /** Everyone at the top: no manager, or one who is no longer here. */ | |
| 116 | + | roots: OrgNode[]; | |
| 117 | + | /** Teams no person leads (an agent does, or no one), with their agents. */ | |
| 118 | + | unled: { team: DirectoryTeam; agents: PeopleAgent[] }[]; | |
| 119 | + | }; | |
| 120 | + | ||
| 121 | + | /** | |
| 122 | + | * Reporting lines, with each team's agents beside the person who leads it. | |
| 123 | + | * A loop (which identity refuses) would leave its people out of `roots`; | |
| 124 | + | * they are put back at the top, so no one is ever missing. | |
| 125 | + | */ | |
| 126 | + | export function orgChart(directory: PeopleDirectory, agents: readonly PeopleAgent[]): OrgChart { | |
| 127 | + | const known = new Set(directory.people.map((person) => person.username)); | |
| 128 | + | const byManager = new Map<string, DirectoryPerson[]>(); | |
| 129 | + | for (const person of directory.people) { | |
| 130 | + | if (person.manager && known.has(person.manager) && person.manager !== person.username) { | |
| 131 | + | byManager.set(person.manager, [...(byManager.get(person.manager) ?? []), person]); | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | const placed = new Set<string>(); | |
| 135 | + | const node = (person: DirectoryPerson): OrgNode => { | |
| 136 | + | placed.add(person.username); | |
| 137 | + | const led = directory.teams.filter((team) => leads(team.lead, { username: person.username })); | |
| 138 | + | const theirAgents: PeopleAgent[] = []; | |
| 139 | + | for (const team of led) for (const agent of agentsOn(team, agents)) if (!theirAgents.includes(agent)) theirAgents.push(agent); | |
| 140 | + | const reports = (byManager.get(person.username) ?? []).filter((report) => !placed.has(report.username)).map(node); | |
| 141 | + | return { person, agents: theirAgents, leads: led, reports }; | |
| 142 | + | }; | |
| 143 | + | const roots = directory.people.filter((person) => !person.manager || !known.has(person.manager)).map(node); | |
| 144 | + | for (const person of directory.people) if (!placed.has(person.username)) roots.push(node(person)); | |
| 145 | + | const unled = directory.teams | |
| 146 | + | .filter((team) => !team.lead || team.lead.kind === "agent") | |
| 147 | + | .map((team) => ({ team, agents: agentsOn(team, agents) })) | |
| 148 | + | .filter((entry) => entry.agents.length > 0); | |
| 149 | + | return { roots, unled }; | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | /** Everyone who reports to `username`, directly. */ | |
| 153 | + | export function reportsOf(people: readonly DirectoryPerson[], username: string): DirectoryPerson[] { | |
| 154 | + | return people.filter((person) => person.manager === username && person.username !== username); | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | /** Who `username` could report to: anyone but themselves and the people under them. */ | |
| 158 | + | export function managerChoices(people: readonly DirectoryPerson[], username: string): DirectoryPerson[] { | |
| 159 | + | const below = new Set<string>([username]); | |
| 160 | + | let grew = true; | |
| 161 | + | while (grew) { | |
| 162 | + | grew = false; | |
| 163 | + | for (const person of people) { | |
| 164 | + | if (person.manager && below.has(person.manager) && !below.has(person.username)) { | |
| 165 | + | below.add(person.username); | |
| 166 | + | grew = true; | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | return people.filter((person) => !below.has(person.username)); | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | /** What someone owns, from a form's text: one per line or comma. */ | |
| 174 | + | export function ownsFromText(text: string): string[] { | |
| 175 | + | return text | |
| 176 | + | .split(/[\n,]+/) | |
| 177 | + | .map((phrase) => phrase.trim()) | |
| 178 | + | .filter(Boolean); | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | /** A monthly budget in dollars from a form, as micro-dollars; 0 for none; null when it isn't a number. */ | |
| 182 | + | export function budgetFromText(text: string): number | null { | |
| 183 | + | const value = text.trim().replace(/^\$/, "").replace(/,/g, ""); | |
| 184 | + | if (!value) return 0; | |
| 185 | + | const dollars = Number(value); | |
| 186 | + | if (!Number.isFinite(dollars) || dollars < 0) return null; | |
| 187 | + | return Math.round(dollars * 1_000_000); | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | /** The access to Code a member has, in words: their role, the base permission, and the teams that add to it. */ | |
| 191 | + | export function codeAccessWords( | |
| 192 | + | person: Pick<DirectoryPerson, "role">, | |
| 193 | + | base: PeopleDirectory["base_permission"], | |
| 194 | + | teams: readonly Pick<DirectoryTeam, "name" | "repos_count">[], | |
| 195 | + | ): { summary: string; through: string[] } { | |
| 196 | + | const summary = | |
| 197 | + | person.role === "owner" | |
| 198 | + | ? "Admin on every repository, as an owner" | |
| 199 | + | : base === "none" | |
| 200 | + | ? "Only repositories they're given a role on" | |
| 201 | + | : `${base[0]!.toUpperCase()}${base.slice(1)} on every repository, the workspace's base permission`; | |
| 202 | + | const through = teams | |
| 203 | + | .filter((team) => team.repos_count > 0) | |
| 204 | + | .map((team) => `Roles on ${team.repos_count} ${team.repos_count === 1 ? "repository" : "repositories"} through ${team.name}`); | |
| 205 | + | return { summary, through }; | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | /** The part of what an agent is told that is about one team: its heading and what follows, up to the next. */ | |
| 209 | + | export function teamBlock(text: string | null, teamName: string): string | null { | |
| 210 | + | if (!text) return null; | |
| 211 | + | const blocks = text.split(/\n(?=### )/); | |
| 212 | + | const found = blocks.find((block) => block.startsWith(`### ${teamName}\n`) || block.trim() === `### ${teamName}`); | |
| 213 | + | return found ? found.trim() : null; | |
| 214 | + | } |
| 118 | 118 | "settings statement statement_entries status status_by_id suggest tree usage usage_meters user_by_username " + | |
| 119 | 119 | "user_for_session user_for_access_token usernames waiting_workspaces workflows workspace workspace_invites github_enabled " + | |
| 120 | 120 | "stars about public_links branch_drift tags last_commits languages contributors license releases release " + | |
| 121 | − | "stargazers starred commit_checks shortcuts spend person_budgets usage_report templates install_requests extension_installs" | |
| 121 | + | "stargazers starred commit_checks shortcuts spend person_budgets usage_report templates install_requests extension_installs " + | |
| 122 | + | "people_directory team_agents team_context team_members" | |
| 122 | 123 | ).split(" "), | |
| 123 | 124 | ); | |
| 124 | 125 |
| 120 | 120 | test("counts leave out what is none", () => { | |
| 121 | 121 | assert.equal(teamCounts({ members_count: 1, repos_count: 0, child_teams_count: 2 }), "1 member · 2 child teams"); | |
| 122 | 122 | assert.equal(teamCounts({ members_count: 0, repos_count: 0, child_teams_count: 0 }), "No members yet"); | |
| 123 | + | assert.equal(teamCounts({ members_count: 2, repos_count: 1, child_teams_count: 0 }, 1), "2 members · 1 agent · 1 repository"); | |
| 124 | + | assert.equal(teamCounts({ members_count: 0, repos_count: 0, child_teams_count: 0 }, 3), "3 agents"); | |
| 123 | 125 | }); | |
| 124 | 126 | ||
| 125 | 127 | test("who may create teams follows the workspace's setting", async () => { |
| 113 | 113 | } | |
| 114 | 114 | ||
| 115 | 115 | /** "3 members · 2 repositories · 1 child team", leaving out what is none. */ | |
| 116 | − | export function teamCounts(team: Pick<Team, "members_count" | "repos_count" | "child_teams_count">): string { | |
| 116 | + | export function teamCounts(team: Pick<Team, "members_count" | "repos_count" | "child_teams_count">, agents = 0): string { | |
| 117 | 117 | const part = (n: number, one: string, many: string) => (n === 0 ? null : `${n} ${n === 1 ? one : many}`); | |
| 118 | 118 | const parts = [ | |
| 119 | 119 | part(team.members_count, "member", "members"), | |
| 120 | + | part(agents, "agent", "agents"), | |
| 120 | 121 | part(team.repos_count, "repository", "repositories"), | |
| 121 | 122 | part(team.child_teams_count, "child team", "child teams"), | |
| 122 | 123 | ].filter(Boolean); |
| 125 | 125 | request(path, { method: "POST", headers: bearer("g1t_web"), body: new URLSearchParams(fields) }); | |
| 126 | 126 | for (const [path, fields] of [ | |
| 127 | 127 | ["/acme/-/settings.data", { intent: "delete" }], | |
| 128 | − | ["/acme/-/people", { action: "transfer", member: "bob" }], | |
| 128 | + | ["/acme/-/members", { action: "transfer", member: "bob" }], | |
| 129 | 129 | ["/acme/-/billing.data", { intent: "portal" }], | |
| 130 | 130 | ["/acme/-/billing", { intent: "card-check" }], | |
| 131 | 131 | ["/acme/-/billing", { intent: "subscribe" }], | |
| ⋯ | |||
| 135 | 135 | } | |
| 136 | 136 | for (const [path, fields] of [ | |
| 137 | 137 | ["/acme/-/settings", { intent: "rename", slug: "acme2" }], | |
| 138 | − | ["/acme/-/people", { action: "role", member: "bob", role: "member" }], | |
| 138 | + | ["/acme/-/members", { action: "role", member: "bob", role: "member" }], | |
| 139 | 139 | ["/acme/-/billing", { intent: "budget" }], | |
| 140 | 140 | ] as const) { | |
| 141 | 141 | assert.equal((await tokenVerdict(post(path, fields), tokens)).kind, "signed-in", `${path} ${JSON.stringify(fields)}`); | |
| 97 | 97 | // Deleting a workspace. | |
| 98 | 98 | { page: /^\/[^/]+\/-\/settings$/, field: "intent", values: ["delete"] }, | |
| 99 | 99 | // Giving a workspace to another owner. | |
| 100 | − | { page: /^\/[^/]+\/-\/people$/, field: "action", values: ["transfer"] }, | |
| 100 | + | { page: /^\/[^/]+\/-\/(?:members|people)$/, field: "action", values: ["transfer"] }, | |
| 101 | 101 | // Payment methods: the card on file, and the payment pages that take one. | |
| 102 | 102 | { page: /^\/[^/]+\/-\/billing$/, field: "intent", values: ["portal", "card-check", "subscribe", "buy-ai-credit"] }, | |
| 103 | 103 | ]; |
| 22 | 22 | }); | |
| 23 | 23 | ||
| 24 | 24 | test("old addresses go to where their pages are now", () => { | |
| 25 | − | assert.equal(workspaceRedirect("/acme/-/members"), "/acme/-/people"); | |
| 25 | + | // Members and invites is a page of People's again. | |
| 26 | + | assert.equal(workspaceRedirect("/acme/-/members"), null); | |
| 26 | 27 | assert.equal(workspaceRedirect("/acme/-/home", "?x=1"), "/acme/-/today?x=1"); | |
| 27 | 28 | // Code's Overview lives at -/overview now: not an old address. | |
| 28 | 29 | assert.equal(workspaceRedirect("/acme/-/overview"), null); | |
| ⋯ | |||
| 41 | 42 | test("?tab= opens that page, keeping the rest of the query", () => { | |
| 42 | 43 | assert.equal(workspaceRedirect("/acme", "?tab=repositories"), "/acme/-/projects"); | |
| 43 | 44 | assert.equal(workspaceRedirect("/acme", "?tab=projects&q=api"), "/acme/-/projects?q=api"); | |
| 44 | − | assert.equal(workspaceRedirect("/acme", "?tab=members"), "/acme/-/people"); | |
| 45 | + | assert.equal(workspaceRedirect("/acme", "?tab=members"), "/acme/-/members"); | |
| 46 | + | assert.equal(workspaceRedirect("/acme", "?tab=people"), "/acme/-/people"); | |
| 45 | 47 | assert.equal(workspaceRedirect("/acme", "?tab=overview"), "/acme"); | |
| 46 | 48 | assert.equal(workspaceRedirect("/acme", "?tab=settings"), "/acme/-/settings"); | |
| 47 | 49 | assert.equal(workspaceRedirect("/acme", "?tab=nonsense"), null); | |
| ⋯ | |||
| 51 | 53 | test("a click's data request is for the same page as a full load", () => { | |
| 52 | 54 | assert.equal(pagePath("/acme/-/insights.data"), "/acme/-/insights"); | |
| 53 | 55 | assert.equal(pagePath("/acme/-/insights/"), "/acme/-/insights"); | |
| 54 | − | assert.equal(workspaceRedirect("/acme/-/members.data", "?_routes=routes%2Fworkspace%2Fmoved-members"), "/acme/-/people"); | |
| 55 | − | assert.equal(workspaceRedirect("/acme/-/members.data", "?_routes=x&q=ada"), "/acme/-/people?q=ada"); | |
| 56 | + | assert.equal(workspaceRedirect("/acme/-/home.data", "?_routes=routes%2Fworkspace%2Fmoved-home"), "/acme/-/today"); | |
| 57 | + | assert.equal(workspaceRedirect("/acme/-/home.data", "?_routes=x&q=ada"), "/acme/-/today?q=ada"); | |
| 56 | 58 | }); | |
| 57 | 59 | ||
| 58 | 60 | test("an alias or old name leads to the same page under the workspace", () => { | |
| ⋯ | |||
| 97 | 99 | assert.equal(modeOf("/acme/-/security/settings", "acme"), "workspace"); | |
| 98 | 100 | assert.equal(modeOf("/acme/-/people", "acme"), "people"); | |
| 99 | 101 | assert.equal(modeOf("/acme/-/teams/web", "acme"), "people"); | |
| 102 | + | assert.equal(modeOf("/acme/-/people/ana", "acme"), "people"); | |
| 103 | + | assert.equal(modeOf("/acme/-/people/agents/margo", "acme"), "people"); | |
| 104 | + | assert.equal(modeOf("/acme/-/org-chart", "acme"), "people"); | |
| 105 | + | assert.equal(modeOf("/acme/-/members", "acme"), "people"); | |
| 100 | 106 | assert.equal(modeOf("/acme/-/apps", "acme"), "apps"); | |
| 101 | 107 | assert.equal(modeOf("/acme/-/billing", "acme"), "workspace"); | |
| 102 | 108 | assert.equal(modeOf("/acme/-/tokens", "acme"), "workspace"); | |
| 52 | 52 | repos: "-/projects", | |
| 53 | 53 | packages: "-/packages", | |
| 54 | 54 | people: "-/people", | |
| 55 | − | members: "-/people", | |
| 55 | + | members: "-/members", | |
| 56 | 56 | teams: "-/teams", | |
| 57 | 57 | insights: "-/insights", | |
| 58 | 58 | settings: "-/settings", | |
| ⋯ | |||
| 61 | 61 | /** Workspace pages that moved, by their old name under `-/`. */ | |
| 62 | 62 | const MOVED: Record<string, string> = { | |
| 63 | 63 | home: "-/today", | |
| 64 | − | members: "-/people", | |
| 65 | 64 | "soon/teams": "-/teams", | |
| 66 | 65 | "soon/insights": "-/insights", | |
| 67 | 66 | }; | |
| ⋯ | |||
| 158 | 157 | people: "people", | |
| 159 | 158 | members: "people", | |
| 160 | 159 | teams: "people", | |
| 160 | + | "org-chart": "people", | |
| 161 | 161 | workspace: "workspace", | |
| 162 | 162 | spend: "workspace", | |
| 163 | 163 | usage: "workspace", | |
| 94 | 94 | index("routes/workspace/overview.tsx"), | |
| 95 | 95 | // Its pages, each in the sidebar: Overview (above), Projects, People, Insights; Teams and Packages below. | |
| 96 | 96 | route("-/projects", "routes/workspace/projects.tsx"), | |
| 97 | + | // People mode: the directory of people and agents, each one's profile, and the org chart. | |
| 97 | 98 | route("-/people", "routes/workspace/people.tsx"), | |
| 99 | + | route("-/people/agents/:handle", "routes/workspace/people/agent.tsx"), | |
| 100 | + | route("-/people/:username", "routes/workspace/people/person.tsx"), | |
| 101 | + | route("-/org-chart", "routes/workspace/org-chart.tsx"), | |
| 102 | + | // Who is a member, their roles, invitations and outside collaborators. | |
| 103 | + | route("-/members", "routes/workspace/members.tsx"), | |
| 98 | 104 | route("-/insights", "routes/workspace/tab-soon.tsx", { id: "routes/workspace/insights" }), | |
| 99 | 105 | // Pinning its projects, for the person signed in. | |
| 100 | 106 | route("-/pins", "routes/workspace/pins.ts"), | |
| 101 | − | // Pages that moved: Members is People, and the overview is the workspace. | |
| 102 | − | route("-/members", "routes/workspace/moved.ts", { id: "routes/workspace/moved-members" }), | |
| 103 | 107 | // Code's Overview: Mission control's code panels, for this workspace. | |
| 104 | 108 | route("-/overview", "routes/workspace/code-overview.tsx"), | |
| 105 | 109 | // The workspace itself, at a glance: members, plan and spend. |
| 186 | 186 | </p> | |
| 187 | 187 | <p className="mt-2 text-muted"> | |
| 188 | 188 | {owner ? ( | |
| 189 | − | <Link to={`/${params.owner}/-/people#base-permission`} className="text-accent hover:underline"> | |
| 189 | + | <Link to={`/${params.owner}/-/members#base-permission`} className="text-accent hover:underline"> | |
| 190 | 190 | Change the base permission | |
| 191 | 191 | </Link> | |
| 192 | 192 | ) : ( |
| 51 | 51 | settings: { title: "General", about: "The workspace's name, icon, address and description, who can create teams, and deleting it." }, | |
| 52 | 52 | people: { | |
| 53 | 53 | title: "People", | |
| 54 | + | about: "Everyone in the workspace, people and agents, in one directory: who they are, what they own, the teams they're on and who's around.", | |
| 55 | + | }, | |
| 56 | + | members: { | |
| 57 | + | title: "Members and invites", | |
| 54 | 58 | about: "Members create repositories and have the base permission on each one. Owners are Admins on every repository, and also manage members, tokens, billing and integrations.", | |
| 55 | 59 | }, | |
| 60 | + | "org-chart": { | |
| 61 | + | title: "Org chart", | |
| 62 | + | about: "Who reports to whom, with each team's agents beside the person who leads it.", | |
| 63 | + | }, | |
| 56 | 64 | projects: { | |
| 57 | 65 | title: "Projects", | |
| 58 | 66 | 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.", | |
| 59 | 67 | }, | |
| 60 | 68 | teams: { | |
| 61 | 69 | title: "Teams", | |
| 62 | − | about: "Groups of members, given roles on repositories together, mentioned as @workspace/team and asked to review together. Child teams inherit their parent's access.", | |
| 70 | + | about: "People, agents, or both, with a lead, a channel, roles on repositories and a budget for their agents. Everyone on a team, agents included, knows who their teammates are.", | |
| 63 | 71 | }, | |
| 64 | 72 | repositories: { | |
| 65 | 73 | title: "Repositories", |
| 1 | + | import { ArrowUpRight, Check, Ellipsis, ShieldAlert, ShieldCheck, Users } from "lucide-react"; | |
| 2 | + | import { useState } from "react"; | |
| 3 | + | import { Form, Link, data, redirect, useSearchParams, useSubmit } from "react-router"; | |
| 4 | + | ||
| 5 | + | import { | |
| 6 | + | BASE_PERMISSIONS, | |
| 7 | + | BASE_PERMISSION_LABELS, | |
| 8 | + | type BasePermission, | |
| 9 | + | DEFAULT_BASE_PERMISSION, | |
| 10 | + | type Member, | |
| 11 | + | ORG_ROLES, | |
| 12 | + | ORG_ROLE_LABELS, | |
| 13 | + | ORG_ROLE_SUMMARIES, | |
| 14 | + | type OrgRole, | |
| 15 | + | REPO_ROLE_LABELS, | |
| 16 | + | } from "@g1t/contracts"; | |
| 17 | + | ||
| 18 | + | import type { Route } from "./+types/members"; | |
| 19 | + | import { page } from "../../lib/meta"; | |
| 20 | + | import { Avatar, Button, CopyLine, ErrorText, Field, Input, Pill, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 21 | + | import { Badge } from "../../components/ui/badge"; | |
| 22 | + | import { PersonStatusEmoji, WithPresence } from "../../components/presence"; | |
| 23 | + | import { | |
| 24 | + | AlertDialog, | |
| 25 | + | AlertDialogCancel, | |
| 26 | + | AlertDialogContent, | |
| 27 | + | AlertDialogDescription, | |
| 28 | + | AlertDialogFooter, | |
| 29 | + | AlertDialogHeader, | |
| 30 | + | AlertDialogTitle, | |
| 31 | + | } from "../../components/ui/alert-dialog"; | |
| 32 | + | import { | |
| 33 | + | DropdownMenu, | |
| 34 | + | DropdownMenuContent, | |
| 35 | + | DropdownMenuItem, | |
| 36 | + | DropdownMenuLabel, | |
| 37 | + | DropdownMenuSeparator, | |
| 38 | + | DropdownMenuTrigger, | |
| 39 | + | } from "../../components/ui/dropdown-menu"; | |
| 40 | + | import { Hint } from "../../components/ui/hint"; | |
| 41 | + | import { forgetWorkspace } from "../../lib/workspace-choice"; | |
| 42 | + | import { Select, SelectContent, SelectField, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; | |
| 43 | + | import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs"; | |
| 44 | + | import { inviteLink, inviteState, moreInvitesMailto, workspaceInviteCopy } from "../../lib/invites"; | |
| 45 | + | import { registrationMode } from "../../lib/registration.server"; | |
| 46 | + | import { billing, identity } from "../../lib/services.server"; | |
| 47 | + | import { StartPlanToInvite } from "../../components/start-plan"; | |
| 48 | + | import { | |
| 49 | + | assertSameOrigin, | |
| 50 | + | getViewer, | |
| 51 | + | requireUser, | |
| 52 | + | roleIn, | |
| 53 | + | unwrap, | |
| 54 | + | } from "../../lib/session.server"; | |
| 55 | + | import { UserCard } from "../../components/user-card"; | |
| 56 | + | import { PeoplePicker } from "../../components/people-picker"; | |
| 57 | + | import { inviteTarget } from "../../lib/people-search"; | |
| 58 | + | ||
| 59 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 60 | + | return page(args, { title: `Members and invites · ${params.owner} · g1t` }); | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 64 | + | const viewer = getViewer(context); | |
| 65 | + | const role = roleIn(viewer, params.owner); | |
| 66 | + | // Who is in a workspace is its members' business; anyone else, including | |
| 67 | + | // someone a repository is shared with, gets nothing here. | |
| 68 | + | if (!role) throw data(null, { status: 404 }); | |
| 69 | + | const owner = role === "owner"; | |
| 70 | + | const [members, invites, workspace, outside, teams, free, mode] = await Promise.all([ | |
| 71 | + | identity.listMembers(params.owner, viewer), | |
| 72 | + | owner ? identity.workspaceInvites(params.owner, viewer).catch(() => null) : null, | |
| 73 | + | identity.getWorkspace(params.owner), | |
| 74 | + | owner ? identity.outsideCollaborators(viewer, params.owner).catch(() => null) : null, | |
| 75 | + | // Each member's teams, as the viewer may see them. | |
| 76 | + | identity.teamMemberships(viewer, params.owner).catch(() => null), | |
| 77 | + | // A free workspace adds no one until it starts the plan; identity | |
| 78 | + | // refuses it either way, so a failure here only hides the note. | |
| 79 | + | billing.freeWorkspaces([params.owner]).catch(() => [] as string[]), | |
| 80 | + | // Whether sign-up takes an invite: the invite form says what that means. | |
| 81 | + | registrationMode(), | |
| 82 | + | ]); | |
| 83 | + | return { | |
| 84 | + | role, | |
| 85 | + | name: workspace?.name?.trim() || params.owner, | |
| 86 | + | inviteOnly: mode !== "open", | |
| 87 | + | free: free.includes(params.owner.toLowerCase()), | |
| 88 | + | members: unwrap(members), | |
| 89 | + | invites: invites?.ok ? invites.value : [], | |
| 90 | + | base: workspace?.basePermission ?? DEFAULT_BASE_PERMISSION, | |
| 91 | + | outside: outside?.ok ? outside.value : [], | |
| 92 | + | teams: Object.fromEntries((teams?.ok ? teams.value : []).map((person) => [person.username, person.teams])), | |
| 93 | + | origin: new URL(request.url).origin, | |
| 94 | + | me: viewer?.username ?? null, | |
| 95 | + | }; | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 99 | + | assertSameOrigin(request); | |
| 100 | + | const user = requireUser(context, request); | |
| 101 | + | const form = await request.formData(); | |
| 102 | + | if (form.get("action") === "revoke-invite") { | |
| 103 | + | const result = await identity.revokeWorkspaceInvite(user, params.owner, String(form.get("id") ?? "")); | |
| 104 | + | return result.ok ? null : { error: result.error.message }; | |
| 105 | + | } | |
| 106 | + | const target = String(form.get("member") ?? "").trim(); | |
| 107 | + | // Owner or member, and the roles that add to a member. | |
| 108 | + | if (form.get("action") === "role") { | |
| 109 | + | const role = form.get("role") === "owner" ? "owner" : "member"; | |
| 110 | + | const result = await identity.updateMember(user, params.owner, target, { role }); | |
| 111 | + | return result.ok ? { changed: target } : { error: result.error.message, row: target }; | |
| 112 | + | } | |
| 113 | + | if (form.get("action") === "org-roles") { | |
| 114 | + | const roles = String(form.get("org_roles") ?? "") | |
| 115 | + | .split(",") | |
| 116 | + | .filter((role): role is OrgRole => (ORG_ROLES as readonly string[]).includes(role)); | |
| 117 | + | const result = await identity.updateMember(user, params.owner, target, { org_roles: roles }); | |
| 118 | + | return result.ok ? { changed: target } : { error: result.error.message, row: target }; | |
| 119 | + | } | |
| 120 | + | if (form.get("action") === "transfer") { | |
| 121 | + | const result = await identity.transferOwnership(user, params.owner, target); | |
| 122 | + | return result.ok ? { transferred: target } : { error: result.error.message, row: target }; | |
| 123 | + | } | |
| 124 | + | if (form.get("action") === "leave") { | |
| 125 | + | const result = await identity.leaveWorkspace(user, params.owner); | |
| 126 | + | if (!result.ok) return { error: result.error.message, leaving: true }; | |
| 127 | + | const secure = new URL(request.url).protocol === "https:"; | |
| 128 | + | throw redirect("/", { headers: { "Set-Cookie": forgetWorkspace(secure) } }); | |
| 129 | + | } | |
| 130 | + | if (form.get("action") === "base-permission") { | |
| 131 | + | const base = String(form.get("base") ?? ""); | |
| 132 | + | if (!(BASE_PERMISSIONS as readonly string[]).includes(base)) return { error: "Choose a base permission." }; | |
| 133 | + | const result = await identity.setBasePermission(user, params.owner, base as BasePermission); | |
| 134 | + | return result.ok ? { based: result.value } : { error: result.error.message, base: true }; | |
| 135 | + | } | |
| 136 | + | const member = String(form.get("member") ?? "").trim(); | |
| 137 | + | if (form.get("action") === "remove") { | |
| 138 | + | const result = await identity.removeMember(user, params.owner, member); | |
| 139 | + | return result.ok ? null : { error: result.error.message, converting: false }; | |
| 140 | + | } | |
| 141 | + | // Nobody joins without saying yes: a username or an address gets an | |
| 142 | + | // invitation to accept or decline, with the role chosen here. | |
| 143 | + | const who = inviteTarget(member); | |
| 144 | + | if (!who) return { error: "Enter a g1t username or an email address.", outOfInvites: false }; | |
| 145 | + | const role = form.get("role") === "owner" ? "owner" : "member"; | |
| 146 | + | const result = await identity.inviteMember(user, params.owner, { ...who, role }); | |
| 147 | + | if (!result.ok) { | |
| 148 | + | return { error: result.error.message, outOfInvites: result.error.code === "limit", converting: form.get("action") === "convert" }; | |
| 149 | + | } | |
| 150 | + | if (form.get("action") === "convert") return { converted: member }; | |
| 151 | + | return { invited: "email" in who ? who.email : `@${"username" in who ? who.username : member}`, outOfInvites: false }; | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | /** What the base permission means for members, in a sentence. */ | |
| 155 | + | const BASE_MEANS: Record<BasePermission, string> = { | |
| 156 | + | none: "Members see only public repositories and the ones they are given a role on.", | |
| 157 | + | read: "Members can see and clone every repository, open issues and pull requests, and comment.", | |
| 158 | + | write: "Members can see every repository, manage its issues and pull requests, push, merge, and put agents to work.", | |
| 159 | + | admin: "Members can do everything on every repository, including its settings, webhooks, secrets and who has access. Only owners transfer or delete one.", | |
| 160 | + | }; | |
| 161 | + | ||
| 162 | + | /** What a member's roles say about them, as badges: owner or member, then the roles on top. */ | |
| 163 | + | function RoleBadges({ member }: { member: Member }) { | |
| 164 | + | return ( | |
| 165 | + | <span className="flex flex-wrap justify-end gap-1"> | |
| 166 | + | {member.role === "owner" ? <Badge tone="accent">Owner</Badge> : <Badge>Member</Badge>} | |
| 167 | + | {(member.org_roles ?? []).map((role) => ( | |
| 168 | + | <Hint key={role} label={ORG_ROLE_SUMMARIES[role]}> | |
| 169 | + | <Badge tone="info" tabIndex={0}> | |
| 170 | + | {ORG_ROLE_LABELS[role]} | |
| 171 | + | </Badge> | |
| 172 | + | </Hint> | |
| 173 | + | ))} | |
| 174 | + | </span> | |
| 175 | + | ); | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | /** Whether a member has two-factor authentication on, for owners. */ | |
| 179 | + | function TwoFactorMark({ on }: { on: boolean | null | undefined }) { | |
| 180 | + | if (on == null) return null; | |
| 181 | + | return on ? ( | |
| 182 | + | <Hint label="Two-factor authentication is on"> | |
| 183 | + | <span tabIndex={0} className="inline-flex text-success" aria-label="Two-factor authentication on"> | |
| 184 | + | <ShieldCheck size={15} /> | |
| 185 | + | </span> | |
| 186 | + | </Hint> | |
| 187 | + | ) : ( | |
| 188 | + | <Hint label="Two-factor authentication is off"> | |
| 189 | + | <span tabIndex={0} className="inline-flex text-warn" aria-label="Two-factor authentication off"> | |
| 190 | + | <ShieldAlert size={15} /> | |
| 191 | + | </span> | |
| 192 | + | </Hint> | |
| 193 | + | ); | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | /** An owner's menu for one member: their role, the roles on top, handing over and removing. */ | |
| 197 | + | function MemberMenu({ member, self, owners, slug }: { member: Member; self: boolean; owners: number; slug: string }) { | |
| 198 | + | const submit = useSubmit(); | |
| 199 | + | const [confirm, setConfirm] = useState<"transfer" | "remove" | null>(null); | |
| 200 | + | const roles = member.org_roles ?? []; | |
| 201 | + | const post = (fields: Record<string, string>) => | |
| 202 | + | submit({ ...fields, member: member.username }, { method: "post", preventScrollReset: true }); | |
| 203 | + | const lastOwner = member.role === "owner" && owners <= 1; | |
| 204 | + | return ( | |
| 205 | + | <> | |
| 206 | + | <DropdownMenu> | |
| 207 | + | <DropdownMenuTrigger asChild> | |
| 208 | + | <button | |
| 209 | + | type="button" | |
| 210 | + | aria-label={`Manage ${member.username}`} | |
| 211 | + | className="rounded-md p-1.5 text-faint transition-colors max-sm:p-2.5 hover:bg-raised hover:text-fg" | |
| 212 | + | > | |
| 213 | + | <Ellipsis size={16} /> | |
| 214 | + | </button> | |
| 215 | + | </DropdownMenuTrigger> | |
| 216 | + | <DropdownMenuContent align="end" className="w-64"> | |
| 217 | + | <DropdownMenuLabel>Role in {slug}</DropdownMenuLabel> | |
| 218 | + | {member.role === "member" ? ( | |
| 219 | + | <DropdownMenuItem onSelect={() => post({ action: "role", role: "owner" })}>Make owner</DropdownMenuItem> | |
| 220 | + | ) : ( | |
| 221 | + | <DropdownMenuItem disabled={lastOwner} onSelect={() => post({ action: "role", role: "member" })}> | |
| 222 | + | {lastOwner ? "The only owner" : "Make member"} | |
| 223 | + | </DropdownMenuItem> | |
| 224 | + | )} | |
| 225 | + | <DropdownMenuSeparator /> | |
| 226 | + | <DropdownMenuLabel>Also</DropdownMenuLabel> | |
| 227 | + | {ORG_ROLES.map((role) => { | |
| 228 | + | const has = roles.includes(role); | |
| 229 | + | const next = has ? roles.filter((other) => other !== role) : [...roles, role]; | |
| 230 | + | return ( | |
| 231 | + | <DropdownMenuItem key={role} onSelect={() => post({ action: "org-roles", org_roles: next.join(",") })}> | |
| 232 | + | <span className="flex size-4 items-center justify-center">{has && <Check />}</span> | |
| 233 | + | {ORG_ROLE_LABELS[role]} | |
| 234 | + | </DropdownMenuItem> | |
| 235 | + | ); | |
| 236 | + | })} | |
| 237 | + | {!self && ( | |
| 238 | + | <> | |
| 239 | + | <DropdownMenuSeparator /> | |
| 240 | + | {member.role === "member" && ( | |
| 241 | + | <DropdownMenuItem onSelect={() => setConfirm("transfer")}>Transfer ownership…</DropdownMenuItem> | |
| 242 | + | )} | |
| 243 | + | <DropdownMenuItem disabled={lastOwner} onSelect={() => setConfirm("remove")} className="text-danger"> | |
| 244 | + | Remove from {slug}… | |
| 245 | + | </DropdownMenuItem> | |
| 246 | + | </> | |
| 247 | + | )} | |
| 248 | + | </DropdownMenuContent> | |
| 249 | + | </DropdownMenu> | |
| 250 | + | <AlertDialog open={confirm !== null} onOpenChange={(open) => !open && setConfirm(null)}> | |
| 251 | + | <AlertDialogContent> | |
| 252 | + | <Form method="post" className="grid gap-4" onSubmit={() => setConfirm(null)}> | |
| 253 | + | <input type="hidden" name="action" value={confirm === "transfer" ? "transfer" : "remove"} /> | |
| 254 | + | <input type="hidden" name="member" value={member.username} /> | |
| 255 | + | <AlertDialogHeader> | |
| 256 | + | <AlertDialogTitle> | |
| 257 | + | {confirm === "transfer" ? `Hand ${slug} to ${member.username}?` : `Remove ${member.username} from ${slug}?`} | |
| 258 | + | </AlertDialogTitle> | |
| 259 | + | <AlertDialogDescription> | |
| 260 | + | {confirm === "transfer" | |
| 261 | + | ? `${member.username} becomes an owner and you a member. Only an owner can make you an owner again.` | |
| 262 | + | : `Their roles on ${slug}'s repositories and their place in its teams go too. To keep them on a repository, add them back to it as an outside collaborator.`} | |
| 263 | + | </AlertDialogDescription> | |
| 264 | + | </AlertDialogHeader> | |
| 265 | + | <AlertDialogFooter> | |
| 266 | + | <AlertDialogCancel type="button">Cancel</AlertDialogCancel> | |
| 267 | + | <SubmitButton variant="danger" match={{ action: confirm ?? "", member: member.username }} pending="Working…"> | |
| 268 | + | {confirm === "transfer" ? "Transfer ownership" : "Remove"} | |
| 269 | + | </SubmitButton> | |
| 270 | + | </AlertDialogFooter> | |
| 271 | + | </Form> | |
| 272 | + | </AlertDialogContent> | |
| 273 | + | </AlertDialog> | |
| 274 | + | </> | |
| 275 | + | ); | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | /** Leaving the workspace, for anyone in it. */ | |
| 279 | + | function LeaveSection({ slug, soleOwner, error }: { slug: string; soleOwner: boolean; error: string | null }) { | |
| 280 | + | const [open, setOpen] = useState(false); | |
| 281 | + | return ( | |
| 282 | + | <section id="leave" className="mt-10 scroll-mt-20 border-t border-line pt-8"> | |
| 283 | + | <h2 className="font-medium">Leave {slug}</h2> | |
| 284 | + | <p className="mt-1 text-sm text-muted"> | |
| 285 | + | {soleOwner | |
| 286 | + | ? `You are ${slug}'s only owner. Make another member an owner first, or delete the workspace in its settings.` | |
| 287 | + | : "Your roles on its repositories and your place in its teams go too. An owner can add you again."} | |
| 288 | + | </p> | |
| 289 | + | <div className="mt-4"> | |
| 290 | + | <Button variant="danger" type="button" disabled={soleOwner} onClick={() => setOpen(true)}> | |
| 291 | + | Leave {slug} | |
| 292 | + | </Button> | |
| 293 | + | </div> | |
| 294 | + | <ErrorText>{error}</ErrorText> | |
| 295 | + | <AlertDialog open={open} onOpenChange={setOpen}> | |
| 296 | + | <AlertDialogContent> | |
| 297 | + | <Form method="post" className="grid gap-4"> | |
| 298 | + | <input type="hidden" name="action" value="leave" /> | |
| 299 | + | <AlertDialogHeader> | |
| 300 | + | <AlertDialogTitle>Leave {slug}?</AlertDialogTitle> | |
| 301 | + | <AlertDialogDescription>You lose access to its private repositories at once.</AlertDialogDescription> | |
| 302 | + | </AlertDialogHeader> | |
| 303 | + | <AlertDialogFooter> | |
| 304 | + | <AlertDialogCancel type="button">Cancel</AlertDialogCancel> | |
| 305 | + | <SubmitButton variant="danger" match={{ action: "leave" }} pending="Leaving…"> | |
| 306 | + | Leave | |
| 307 | + | </SubmitButton> | |
| 308 | + | </AlertDialogFooter> | |
| 309 | + | </Form> | |
| 310 | + | </AlertDialogContent> | |
| 311 | + | </AlertDialog> | |
| 312 | + | </section> | |
| 313 | + | ); | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | export default function WorkspacePeople({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 317 | + | const { role, members, invites, origin, base, outside, teams, free, me, name, inviteOnly } = loaderData; | |
| 318 | + | const owner = role === "owner"; | |
| 319 | + | // The invitation to join this workspace, not an invite to g1t (Settings → Invites). | |
| 320 | + | const inviteCopy = workspaceInviteCopy(name, inviteOnly); | |
| 321 | + | const owners = members.filter((member) => member.role === "owner").length; | |
| 322 | + | const rowError = (username: string) => | |
| 323 | + | actionData && "row" in actionData && actionData.row === username ? (actionData.error ?? null) : null; | |
| 324 | + | // Waiting to be used, or used by someone still confirming their email: | |
| 325 | + | // either can be revoked. | |
| 326 | + | // Waiting for an answer to a workspace invitation, too. | |
| 327 | + | const pending = invites.filter( | |
| 328 | + | (invite) => invite.status === "pending" || invite.status === "awaiting_confirmation" || invite.status === "awaiting_answer", | |
| 329 | + | ); | |
| 330 | + | const [search, setSearch] = useSearchParams(); | |
| 331 | + | const tab = search.get("tab") === "outside" && owner ? "outside" : "members"; | |
| 332 | + | const membersTab = ( | |
| 333 | + | <> | |
| 334 | + | <ul className="divide-y divide-line rounded-xl border border-line"> | |
| 335 | + | {members.map((member) => ( | |
| 336 | + | <li key={member.username} className="flex flex-wrap items-center gap-3 px-4 py-3"> | |
| 337 | + | <WithPresence person={{ username: member.username }} size={28}> | |
| 338 | + | <Avatar name={member.username} image={member.avatar} size={28} /> | |
| 339 | + | </WithPresence> | |
| 340 | + | <div className="min-w-0 grow truncate"> | |
| 341 | + | <UserCard username={member.username}> | |
| 342 | + | <Link to={`/u/${member.username}`} className="font-mono text-sm hover:text-accent"> | |
| 343 | + | {member.username} | |
| 344 | + | </Link> | |
| 345 | + | </UserCard> | |
| 346 | + | <PersonStatusEmoji person={{ username: member.username }} size={13} className="ml-1.5 align-[-2px]" /> | |
| 347 | + | {member.name && <span className="ml-2 hidden text-sm text-muted sm:inline">{member.name}</span>} | |
| 348 | + | {(teams[member.username] ?? []).length > 0 && ( | |
| 349 | + | <div className="mt-1 flex flex-wrap gap-1"> | |
| 350 | + | {(teams[member.username] ?? []).slice(0, 3).map((team) => ( | |
| 351 | + | <Link | |
| 352 | + | key={team.slug} | |
| 353 | + | to={`/${params.owner}/-/teams/${team.slug}`} | |
| 354 | + | className="rounded-full border border-line px-2 py-px text-xs text-muted transition-colors hover:border-line-strong hover:text-fg" | |
| 355 | + | > | |
| 356 | + | {team.name} | |
| 357 | + | </Link> | |
| 358 | + | ))} | |
| 359 | + | {(teams[member.username] ?? []).length > 3 && ( | |
| 360 | + | <span className="px-1 text-xs text-faint">+{(teams[member.username] ?? []).length - 3} more</span> | |
| 361 | + | )} | |
| 362 | + | </div> | |
| 363 | + | )} | |
| 364 | + | </div> | |
| 365 | + | {/* Together, so on a narrow screen they move under the name as one. */} | |
| 366 | + | <div className="ml-auto flex items-center gap-2"> | |
| 367 | + | {owner && <TwoFactorMark on={member.two_factor} />} | |
| 368 | + | <RoleBadges member={member} /> | |
| 369 | + | {owner && <MemberMenu member={member} self={member.username === me} owners={owners} slug={params.owner} />} | |
| 370 | + | </div> | |
| 371 | + | {rowError(member.username) && ( | |
| 372 | + | <p className="basis-full text-sm text-danger" role="alert"> | |
| 373 | + | {rowError(member.username)} | |
| 374 | + | </p> | |
| 375 | + | )} | |
| 376 | + | </li> | |
| 377 | + | ))} | |
| 378 | + | </ul> | |
| 379 | + | {owner && free && ( | |
| 380 | + | <div className="mt-6"> | |
| 381 | + | <StartPlanToInvite workspace={params.owner} owner={owner} /> | |
| 382 | + | </div> | |
| 383 | + | )} | |
| 384 | + | {owner && !free && ( | |
| 385 | + | // Empty again once the person is on the list; kept as typed when it failed. | |
| 386 | + | <section aria-labelledby="invite-to-workspace" className="mt-6"> | |
| 387 | + | <h2 id="invite-to-workspace" className="text-sm font-medium"> | |
| 388 | + | {inviteCopy.heading} | |
| 389 | + | </h2> | |
| 390 | + | <p className="mt-1 text-xs text-faint">{inviteCopy.hint}</p> | |
| 391 | + | <Form | |
| 392 | + | method="post" | |
| 393 | + | key={`${members.length}:${pending.length}`} | |
| 394 | + | className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-start" | |
| 395 | + | > | |
| 396 | + | <input type="hidden" name="action" value="add" /> | |
| 397 | + | <div className="grow"> | |
| 398 | + | <Field label="Who"> | |
| 399 | + | <PeoplePicker name="member" placeholder="username, name or name@example.com" /> | |
| 400 | + | </Field> | |
| 401 | + | </div> | |
| 402 | + | <label className="block"> | |
| 403 | + | <span className="mb-1.5 block text-sm font-medium text-muted">Role</span> | |
| 404 | + | <SelectField | |
| 405 | + | name="role" | |
| 406 | + | defaultValue="member" | |
| 407 | + | className="h-auto w-full py-2 sm:w-auto sm:min-w-32" | |
| 408 | + | options={[ | |
| 409 | + | { value: "member", label: "Member" }, | |
| 410 | + | { value: "owner", label: "Owner" }, | |
| 411 | + | ]} | |
| 412 | + | /> | |
| 413 | + | </label> | |
| 414 | + | <div className="sm:pt-[1.625rem]"> | |
| 415 | + | <SubmitButton match={{ action: "add" }} pending="Inviting…"> | |
| 416 | + | Invite | |
| 417 | + | </SubmitButton> | |
| 418 | + | </div> | |
| 419 | + | </Form> | |
| 420 | + | {inviteCopy.elsewhere && ( | |
| 421 | + | <p className="mt-2 text-xs text-faint"> | |
| 422 | + | {inviteCopy.elsewhere.replace(/Settings → Invites\.$/, "")} | |
| 423 | + | <Link to="/settings/invites" className="text-muted underline underline-offset-4 hover:text-fg"> | |
| 424 | + | Settings → Invites | |
| 425 | + | </Link> | |
| 426 | + | . | |
| 427 | + | </p> | |
| 428 | + | )} | |
| 429 | + | </section> | |
| 430 | + | )} | |
| 431 | + | {actionData && "invited" in actionData && actionData.invited && ( | |
| 432 | + | <p className="text-sm text-muted" role="status"> | |
| 433 | + | Invitation to join {name} sent to <span className="text-fg">{actionData.invited}</span>. They join once they accept. | |
| 434 | + | </p> | |
| 435 | + | )} | |
| 436 | + | {actionData && "transferred" in actionData && ( | |
| 437 | + | <p className="text-sm text-muted" role="status"> | |
| 438 | + | <span className="font-mono text-fg">{actionData.transferred}</span> owns {params.owner} now, and you are a member. | |
| 439 | + | </p> | |
| 440 | + | )} | |
| 441 | + | {actionData && "converted" in actionData && ( | |
| 442 | + | <p className="text-sm text-muted" role="status"> | |
| 443 | + | <span className="font-mono text-fg">{actionData.converted}</span> is invited to join {params.owner} as a member, and joins once they accept. | |
| 444 | + | </p> | |
| 445 | + | )} | |
| 446 | + | {actionData && | |
| 447 | + | !("base" in actionData) && | |
| 448 | + | !("row" in actionData) && | |
| 449 | + | !("leaving" in actionData) && | |
| 450 | + | !("converting" in actionData && actionData.converting) && ( | |
| 451 | + | <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText> | |
| 452 | + | )} | |
| 453 | + | {actionData && "outOfInvites" in actionData && actionData.outOfInvites && ( | |
| 454 | + | <p className="mt-1 text-sm text-muted"> | |
| 455 | + | <a href={moreInvitesMailto(params.owner)} className="text-accent underline underline-offset-4"> | |
| 456 | + | Ask for more invites | |
| 457 | + | </a>{" "} | |
| 458 | + | for {params.owner}. | |
| 459 | + | </p> | |
| 460 | + | )} | |
| 461 | + | ||
| 462 | + | {owner && pending.length > 0 && ( | |
| 463 | + | <section className="mt-8"> | |
| 464 | + | <h2 className="text-sm font-medium">Pending invitations</h2> | |
| 465 | + | <ul className="mt-3 divide-y divide-line rounded-xl border border-line"> | |
| 466 | + | {pending.map((invite) => ( | |
| 467 | + | <li key={invite.id} className="space-y-2 px-4 py-3"> | |
| 468 | + | <div className="flex flex-wrap items-center gap-3"> | |
| 469 | + | <span className="min-w-0 grow truncate text-sm"> | |
| 470 | + | {invite.email ?? | |
| 471 | + | (invite.invitee ? ( | |
| 472 | + | <UserCard username={invite.invitee}> | |
| 473 | + | <Link to={`/u/${invite.invitee}`} className="font-mono hover:text-accent"> | |
| 474 | + | @{invite.invitee} | |
| 475 | + | </Link> | |
| 476 | + | </UserCard> | |
| 477 | + | ) : ( | |
| 478 | + | "Anyone with the link" | |
| 479 | + | ))} | |
| 480 | + | </span> | |
| 481 | + | {invite.role === "owner" && <Pill>Owner</Pill>} | |
| 482 | + | <Pill>{inviteState(invite).label}</Pill> | |
| 483 | + | <Form method="post"> | |
| 484 | + | <input type="hidden" name="action" value="revoke-invite" /> | |
| 485 | + | <input type="hidden" name="id" value={invite.id} /> | |
| 486 | + | <SubmitButton variant="quiet" match={{ action: "revoke-invite", id: invite.id }} pending="Revoking…"> | |
| 487 | + | Revoke | |
| 488 | + | </SubmitButton> | |
| 489 | + | </Form> | |
| 490 | + | </div> | |
| 491 | + | <p className="text-xs text-faint"> | |
| 492 | + | By {invite.invitedBy ?? "g1t"} · <TimeAgo at={invite.createdAt} /> · works until{" "} | |
| 493 | + | {new Date(invite.expiresAt).toISOString().slice(0, 10)} | |
| 494 | + | </p> | |
| 495 | + | {invite.code && <CopyLine text={inviteLink(invite.code, origin)} />} | |
| 496 | + | </li> | |
| 497 | + | ))} | |
| 498 | + | </ul> | |
| 499 | + | </section> | |
| 500 | + | )} | |
| 501 | + | ||
| 502 | + | <BasePermissionSection | |
| 503 | + | // Starts from the saved choice whenever it changes. | |
| 504 | + | key={base} | |
| 505 | + | base={base} | |
| 506 | + | owner={owner} | |
| 507 | + | slug={params.owner} | |
| 508 | + | error={actionData && "base" in actionData && "error" in actionData ? (actionData.error ?? null) : null} | |
| 509 | + | saved={Boolean(actionData && "based" in actionData)} | |
| 510 | + | /> | |
| 511 | + | ||
| 512 | + | <LeaveSection | |
| 513 | + | slug={params.owner} | |
| 514 | + | soleOwner={owner && owners <= 1} | |
| 515 | + | error={actionData && "leaving" in actionData ? (actionData.error ?? null) : null} | |
| 516 | + | /> | |
| 517 | + | </> | |
| 518 | + | ); | |
| 519 | + | return ( | |
| 520 | + | <div className="max-w-2xl"> | |
| 521 | + | {owner ? ( | |
| 522 | + | <Tabs | |
| 523 | + | value={tab} | |
| 524 | + | onValueChange={(value) => { | |
| 525 | + | const next = new URLSearchParams(search); | |
| 526 | + | if (value === "outside") next.set("tab", "outside"); | |
| 527 | + | else next.delete("tab"); | |
| 528 | + | setSearch(next, { replace: true, preventScrollReset: true }); | |
| 529 | + | }} | |
| 530 | + | > | |
| 531 | + | <TabsList className="mb-3"> | |
| 532 | + | <TabsTrigger value="members">Members · {members.length}</TabsTrigger> | |
| 533 | + | <TabsTrigger value="outside">Outside collaborators · {outside.length}</TabsTrigger> | |
| 534 | + | </TabsList> | |
| 535 | + | <TabsContent value="members">{membersTab}</TabsContent> | |
| 536 | + | <TabsContent value="outside"> | |
| 537 | + | <OutsideCollaborators | |
| 538 | + | people={outside} | |
| 539 | + | slug={params.owner} | |
| 540 | + | free={free} | |
| 541 | + | error={actionData && "converting" in actionData && actionData.converting ? actionData.error : null} | |
| 542 | + | /> | |
| 543 | + | </TabsContent> | |
| 544 | + | </Tabs> | |
| 545 | + | ) : ( | |
| 546 | + | membersTab | |
| 547 | + | )} | |
| 548 | + | </div> | |
| 549 | + | ); | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | /** What members get on every repository, which owners choose. */ | |
| 553 | + | function BasePermissionSection({ | |
| 554 | + | base, | |
| 555 | + | owner, | |
| 556 | + | slug, | |
| 557 | + | error, | |
| 558 | + | saved, | |
| 559 | + | }: { | |
| 560 | + | base: BasePermission; | |
| 561 | + | owner: boolean; | |
| 562 | + | slug: string; | |
| 563 | + | error: string | null; | |
| 564 | + | saved: boolean; | |
| 565 | + | }) { | |
| 566 | + | const [chosen, setChosen] = useState<BasePermission>(base); | |
| 567 | + | return ( | |
| 568 | + | <section id="base-permission" className="mt-10 scroll-mt-20 border-t border-line pt-8"> | |
| 569 | + | <h2 className="font-medium">Base permission</h2> | |
| 570 | + | <p className="mt-1 text-sm text-muted"> | |
| 571 | + | The role every member has on each of {slug}'s repositories. Owners are Admins on all of them, and a role given to | |
| 572 | + | someone on one repository adds to this; it never takes away. | |
| 573 | + | </p> | |
| 574 | + | {owner ? ( | |
| 575 | + | <Form method="post" className="mt-4 rounded-xl border border-line bg-surface p-4"> | |
| 576 | + | <input type="hidden" name="action" value="base-permission" /> | |
| 577 | + | <div className="flex flex-col gap-3 sm:flex-row sm:items-center"> | |
| 578 | + | <Select name="base" value={chosen} onValueChange={(value) => setChosen(value as BasePermission)}> | |
| 579 | + | <SelectTrigger aria-label="Base permission" className="sm:max-w-48"> | |
| 580 | + | <SelectValue /> | |
| 581 | + | </SelectTrigger> | |
| 582 | + | <SelectContent> | |
| 583 | + | {BASE_PERMISSIONS.map((value) => ( | |
| 584 | + | <SelectItem key={value} value={value} description={BASE_MEANS[value]}> | |
| 585 | + | {BASE_PERMISSION_LABELS[value]} | |
| 586 | + | </SelectItem> | |
| 587 | + | ))} | |
| 588 | + | </SelectContent> | |
| 589 | + | </Select> | |
| 590 | + | <SubmitButton variant="quiet" match={{ action: "base-permission" }} pending="Saving…" disabled={chosen === base}> | |
| 591 | + | Save | |
| 592 | + | </SubmitButton> | |
| 593 | + | {saved && chosen === base && <span className="text-sm text-muted">Saved.</span>} | |
| 594 | + | </div> | |
| 595 | + | <p className="mt-3 text-sm text-muted">{BASE_MEANS[chosen]}</p> | |
| 596 | + | <ErrorText>{error}</ErrorText> | |
| 597 | + | </Form> | |
| 598 | + | ) : ( | |
| 599 | + | <div className="mt-4 rounded-xl border border-line bg-surface p-4 text-sm"> | |
| 600 | + | <p> | |
| 601 | + | <span className="font-medium">{BASE_PERMISSION_LABELS[base]}.</span>{" "} | |
| 602 | + | <span className="text-muted">{BASE_MEANS[base]}</span> | |
| 603 | + | </p> | |
| 604 | + | <p className="mt-2 text-xs text-faint">Owners of the workspace choose it.</p> | |
| 605 | + | </div> | |
| 606 | + | )} | |
| 607 | + | </section> | |
| 608 | + | ); | |
| 609 | + | } | |
| 610 | + | ||
| 611 | + | /** People with a role on some of the workspace's repositories who are not members of it. */ | |
| 612 | + | function OutsideCollaborators({ | |
| 613 | + | people, | |
| 614 | + | slug, | |
| 615 | + | free, | |
| 616 | + | error, | |
| 617 | + | }: { | |
| 618 | + | people: Route.ComponentProps["loaderData"]["outside"]; | |
| 619 | + | slug: string; | |
| 620 | + | free: boolean; | |
| 621 | + | error: string | null | undefined; | |
| 622 | + | }) { | |
| 623 | + | return ( | |
| 624 | + | <> | |
| 625 | + | <p className="mb-4 text-sm text-muted"> | |
| 626 | + | People given a role on one or more of {slug}'s repositories without being members. They see only those | |
| 627 | + | repositories. Change or take away a role on the repository's Access settings. | |
| 628 | + | </p> | |
| 629 | + | {free && ( | |
| 630 | + | <div className="mb-4"> | |
| 631 | + | <StartPlanToInvite workspace={slug} owner /> | |
| 632 | + | </div> | |
| 633 | + | )} | |
| 634 | + | {people.length === 0 ? ( | |
| 635 | + | <div className="rounded-xl border border-dashed border-line px-6 py-10 text-center"> | |
| 636 | + | <Users size={18} className="mx-auto text-faint" /> | |
| 637 | + | <p className="mt-2 text-sm font-medium">No outside collaborators</p> | |
| 638 | + | <p className="mt-1 text-sm text-muted">Add someone to a single repository from its Settings → Access.</p> | |
| 639 | + | </div> | |
| 640 | + | ) : ( | |
| 641 | + | <ul className="divide-y divide-line rounded-xl border border-line"> | |
| 642 | + | {people.map((person) => ( | |
| 643 | + | <li key={person.username} className="px-4 py-3"> | |
| 644 | + | <div className="flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 645 | + | <Avatar name={person.username} image={person.avatar} size={28} /> | |
| 646 | + | <div className="min-w-0 grow basis-32"> | |
| 647 | + | <UserCard username={person.username}> | |
| 648 | + | <Link to={`/u/${person.username}`} className="font-mono text-sm hover:text-accent"> | |
| 649 | + | {person.username} | |
| 650 | + | </Link> | |
| 651 | + | </UserCard> | |
| 652 | + | {person.name && <span className="ml-2 text-sm text-muted">{person.name}</span>} | |
| 653 | + | </div> | |
| 654 | + | {!free && ( | |
| 655 | + | <Form method="post"> | |
| 656 | + | <input type="hidden" name="action" value="convert" /> | |
| 657 | + | <input type="hidden" name="member" value={person.username} /> | |
| 658 | + | <SubmitButton variant="quiet" match={{ action: "convert", member: person.username }} pending="Inviting…"> | |
| 659 | + | Invite as a member | |
| 660 | + | </SubmitButton> | |
| 661 | + | </Form> | |
| 662 | + | )} | |
| 663 | + | </div> | |
| 664 | + | <ul className="mt-2 flex flex-wrap gap-1.5 pl-10"> | |
| 665 | + | {person.repos.map((grant) => ( | |
| 666 | + | <li key={grant.repo}> | |
| 667 | + | <Link | |
| 668 | + | to={`/${grant.repo}/settings/access`} | |
| 669 | + | className="group inline-flex items-center gap-1.5 rounded-full border border-line px-2 py-0.5 text-xs transition-colors hover:border-line-strong hover:bg-surface" | |
| 670 | + | > | |
| 671 | + | <span className="font-mono text-fg/90">{grant.repo}</span> | |
| 672 | + | <span className="text-muted">{REPO_ROLE_LABELS[grant.role]}</span> | |
| 673 | + | <ArrowUpRight size={11} className="text-faint group-hover:text-fg" /> | |
| 674 | + | </Link> | |
| 675 | + | </li> | |
| 676 | + | ))} | |
| 677 | + | </ul> | |
| 678 | + | </li> | |
| 679 | + | ))} | |
| 680 | + | </ul> | |
| 681 | + | )} | |
| 682 | + | <ErrorText>{error}</ErrorText> | |
| 683 | + | </> | |
| 684 | + | ); | |
| 685 | + | } |
| 1 | 1 | /** | |
| 2 | − | * Workspace pages that moved: `-/members` is People now, and `-/overview` | |
| 2 | + | * Workspace pages that moved: `-/home` is Today now, and `-/overview` | |
| 3 | 3 | * the workspace's own page (lib/workspace-nav.ts). | |
| 4 | 4 | */ | |
| 5 | 5 | import { data, redirect } from "react-router"; |
| 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 { Hint } from "../../components/ui/hint"; | |
| 7 | + | import { page } from "../../lib/meta"; | |
| 8 | + | import { type OrgNode, agentPath, orgChart, peopleAgent, personName, personPath } from "../../lib/people"; | |
| 9 | + | import { teamPath } from "../../lib/teams"; | |
| 10 | + | import { identity, workspaceAgents } from "../../lib/services.server"; | |
| 11 | + | import { getViewer, roleIn, unwrap } from "../../lib/session.server"; | |
| 12 | + | ||
| 13 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 14 | + | return page(args, { title: `Org chart · ${params.owner} · g1t` }); | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 18 | + | const viewer = getViewer(context); | |
| 19 | + | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 20 | + | const [directory, agents] = await Promise.all([ | |
| 21 | + | identity.peopleDirectory(viewer, params.owner).then(unwrap), | |
| 22 | + | workspaceAgents.list(params.owner, viewer!).catch(() => null), | |
| 23 | + | ]); | |
| 24 | + | return { slug: params.owner.toLowerCase(), directory, agents: agents?.ok ? agents.value.map(peopleAgent) : [] }; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | export default function OrgChartPage({ loaderData }: Route.ComponentProps) { | |
| 28 | + | const { slug, directory, agents } = loaderData; | |
| 29 | + | const chart = orgChart(directory, agents); | |
| 30 | + | const lines = directory.people.some((person) => person.manager); | |
| 31 | + | ||
| 32 | + | return ( | |
| 33 | + | <div className="space-y-6"> | |
| 34 | + | {!lines && ( | |
| 35 | + | <p className="flex items-start gap-2 rounded-lg border border-line bg-surface px-3 py-2.5 text-sm text-muted"> | |
| 36 | + | <Info size={15} className="mt-0.5 shrink-0 text-faint" /> | |
| 37 | + | {directory.can_manage | |
| 38 | + | ? "No one reports to anyone yet. Set who each person reports to under Edit profile on their profile." | |
| 39 | + | : "No reporting lines yet. An owner sets who each person reports to on their profile."} | |
| 40 | + | </p> | |
| 41 | + | )} | |
| 42 | + | <section aria-label="Reporting lines" className="overflow-x-auto rounded-xl border border-line bg-surface p-4 sm:p-5"> | |
| 43 | + | <ul className="space-y-1"> | |
| 44 | + | {chart.roots.map((root) => ( | |
| 45 | + | <Branch key={root.person.username} slug={slug} node={root} /> | |
| 46 | + | ))} | |
| 47 | + | </ul> | |
| 48 | + | <p className="mt-4 text-xs text-faint">Agents appear beside the person who leads their team.</p> | |
| 49 | + | </section> | |
| 50 | + | ||
| 51 | + | {chart.unled.length > 0 && ( | |
| 52 | + | <section aria-labelledby="unled" className="space-y-3"> | |
| 53 | + | <h2 id="unled" className="text-sm font-medium text-muted"> | |
| 54 | + | Teams no person leads | |
| 55 | + | </h2> | |
| 56 | + | <ul className="grid gap-3 sm:grid-cols-2"> | |
| 57 | + | {chart.unled.map(({ team, agents: teamAgents }) => ( | |
| 58 | + | <li key={team.slug} className="rounded-xl border border-line bg-surface p-4"> | |
| 59 | + | <div className="flex items-center gap-2"> | |
| 60 | + | <TeamKindMark people={team.people.length} agents={teamAgents.length} /> | |
| 61 | + | <Link to={teamPath(slug, team.slug)} className="font-medium hover:text-accent"> | |
| 62 | + | {team.name} | |
| 63 | + | </Link> | |
| 64 | + | {team.lead?.kind === "agent" && ( | |
| 65 | + | <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> | |
| 66 | + | )} | |
| 67 | + | </div> | |
| 68 | + | <div className="mt-3 flex flex-wrap gap-2"> | |
| 69 | + | {teamAgents.map((agent) => ( | |
| 70 | + | <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"> | |
| 71 | + | <AgentPersonFace agent={agent} size={20} ring="var(--color-surface)" /> | |
| 72 | + | {agent.display_name} | |
| 73 | + | </Link> | |
| 74 | + | ))} | |
| 75 | + | </div> | |
| 76 | + | </li> | |
| 77 | + | ))} | |
| 78 | + | </ul> | |
| 79 | + | </section> | |
| 80 | + | )} | |
| 81 | + | </div> | |
| 82 | + | ); | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /** One person, the agents of the teams they lead, and everyone who reports to them below. */ | |
| 86 | + | function Branch({ slug, node }: { slug: string; node: OrgNode }) { | |
| 87 | + | const { person } = node; | |
| 88 | + | return ( | |
| 89 | + | <li> | |
| 90 | + | <div className="flex flex-wrap items-center gap-2 py-1"> | |
| 91 | + | <Link | |
| 92 | + | to={personPath(slug, person.username)} | |
| 93 | + | prefetch="intent" | |
| 94 | + | 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" | |
| 95 | + | > | |
| 96 | + | <PersonFace person={person} size={28} /> | |
| 97 | + | <span className="grid min-w-0 text-left"> | |
| 98 | + | <span className="truncate text-sm font-medium">{personName(person)}</span> | |
| 99 | + | <span className="truncate text-xs text-muted">{person.title ?? `@${person.username}`}</span> | |
| 100 | + | </span> | |
| 101 | + | </Link> | |
| 102 | + | {node.agents.length > 0 && ( | |
| 103 | + | <span className="inline-flex items-center gap-2"> | |
| 104 | + | <span className="flex"> | |
| 105 | + | {node.agents.slice(0, 6).map((agent, index) => ( | |
| 106 | + | <Hint key={agent.id} label={`${agent.display_name} · ${agent.title || agent.role}`}> | |
| 107 | + | <Link to={agentPath(slug, agent.handle)} className={index ? "-ml-1.5" : undefined} aria-label={agent.display_name}> | |
| 108 | + | <AgentPersonFace agent={agent} size={22} ring="var(--color-surface)" /> | |
| 109 | + | </Link> | |
| 110 | + | </Hint> | |
| 111 | + | ))} | |
| 112 | + | </span> | |
| 113 | + | <span className="text-xs text-faint"> | |
| 114 | + | {node.agents.length > 6 ? `+${node.agents.length - 6} · ` : ""} | |
| 115 | + | {node.leads.map((team) => team.name).join(", ")} | |
| 116 | + | </span> | |
| 117 | + | </span> | |
| 118 | + | )} | |
| 119 | + | </div> | |
| 120 | + | {node.reports.length > 0 && ( | |
| 121 | + | <ul className="ml-[17px] space-y-1 border-l border-dashed border-line-strong pl-5 sm:pl-7"> | |
| 122 | + | {node.reports.map((report) => ( | |
| 123 | + | <Branch key={report.person.username} slug={slug} node={report} /> | |
| 124 | + | ))} | |
| 125 | + | </ul> | |
| 126 | + | )} | |
| 127 | + | </li> | |
| 128 | + | ); | |
| 129 | + | } |
| 379 | 379 | <section> | |
| 380 | 380 | <div className="flex items-center justify-between"> | |
| 381 | 381 | <h2 className="text-sm font-medium">Members</h2> | |
| 382 | − | <Link to={`/${slug}/-/people`} className="text-xs text-muted hover:text-fg"> | |
| 382 | + | <Link to={role === "owner" ? `/${slug}/-/members` : `/${slug}/-/people`} className="text-xs text-muted hover:text-fg"> | |
| 383 | 383 | {role === "owner" ? "Manage" : "See all"} | |
| 384 | 384 | </Link> | |
| 385 | 385 | </div> |
| 1 | − | import { ArrowUpRight, Check, Ellipsis, ShieldAlert, ShieldCheck, Users } from "lucide-react"; | |
| 2 | − | import { useState } from "react"; | |
| 3 | − | import { Form, Link, data, redirect, useSearchParams, useSubmit } from "react-router"; | |
| 4 | − | ||
| 5 | − | import { | |
| 6 | − | BASE_PERMISSIONS, | |
| 7 | − | BASE_PERMISSION_LABELS, | |
| 8 | − | type BasePermission, | |
| 9 | − | DEFAULT_BASE_PERMISSION, | |
| 10 | − | type Member, | |
| 11 | − | ORG_ROLES, | |
| 12 | − | ORG_ROLE_LABELS, | |
| 13 | − | ORG_ROLE_SUMMARIES, | |
| 14 | − | type OrgRole, | |
| 15 | − | REPO_ROLE_LABELS, | |
| 16 | − | } from "@g1t/contracts"; | |
| 1 | + | import { Network, Search, UserPlus } from "lucide-react"; | |
| 2 | + | import { Link, data, useSearchParams, type ShouldRevalidateFunctionArgs } from "react-router"; | |
| 17 | 3 | ||
| 18 | 4 | import type { Route } from "./+types/people"; | |
| 5 | + | import { AgentPersonFace, AgentTag, LocalTime, PersonFace } from "../../components/people"; | |
| 6 | + | import { ButtonLink, notACredential } from "../../components/ui"; | |
| 7 | + | import { Badge } from "../../components/ui/badge"; | |
| 19 | 8 | import { page } from "../../lib/meta"; | |
| 20 | − | import { Avatar, Button, CopyLine, ErrorText, Field, Input, Pill, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 21 | − | import { Badge } from "../../components/ui/badge"; | |
| 22 | − | import { PersonStatusEmoji, WithPresence } from "../../components/presence"; | |
| 23 | − | import { | |
| 24 | − | AlertDialog, | |
| 25 | − | AlertDialogCancel, | |
| 26 | − | AlertDialogContent, | |
| 27 | − | AlertDialogDescription, | |
| 28 | − | AlertDialogFooter, | |
| 29 | − | AlertDialogHeader, | |
| 30 | − | AlertDialogTitle, | |
| 31 | − | } from "../../components/ui/alert-dialog"; | |
| 32 | − | import { | |
| 33 | − | DropdownMenu, | |
| 34 | − | DropdownMenuContent, | |
| 35 | − | DropdownMenuItem, | |
| 36 | − | DropdownMenuLabel, | |
| 37 | − | DropdownMenuSeparator, | |
| 38 | − | DropdownMenuTrigger, | |
| 39 | − | } from "../../components/ui/dropdown-menu"; | |
| 40 | − | import { Hint } from "../../components/ui/hint"; | |
| 41 | − | import { forgetWorkspace } from "../../lib/workspace-choice"; | |
| 42 | − | import { Select, SelectContent, SelectField, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; | |
| 43 | − | import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs"; | |
| 44 | − | import { inviteLink, inviteState, moreInvitesMailto, workspaceInviteCopy } from "../../lib/invites"; | |
| 45 | − | import { registrationMode } from "../../lib/registration.server"; | |
| 46 | − | import { billing, identity } from "../../lib/services.server"; | |
| 47 | − | import { StartPlanToInvite } from "../../components/start-plan"; | |
| 48 | − | import { | |
| 49 | − | assertSameOrigin, | |
| 50 | − | getViewer, | |
| 51 | − | requireUser, | |
| 52 | − | roleIn, | |
| 53 | − | unwrap, | |
| 54 | − | } from "../../lib/session.server"; | |
| 55 | − | import { UserCard } from "../../components/user-card"; | |
| 56 | − | import { PeoplePicker } from "../../components/people-picker"; | |
| 57 | − | import { inviteTarget } from "../../lib/people-search"; | |
| 9 | + | import { type DirectoryEntry, type DirectoryKind, agentPath, directoryEntries, directoryKind, peopleAgent, personName, personPath } from "../../lib/people"; | |
| 10 | + | import { identity, workspaceAgents } from "../../lib/services.server"; | |
| 11 | + | import { getViewer, roleIn, unwrap } from "../../lib/session.server"; | |
| 58 | 12 | ||
| 59 | 13 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 60 | 14 | return page(args, { title: `People · ${params.owner} · g1t` }); | |
| 61 | 15 | } | |
| 62 | 16 | ||
| 63 | − | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 17 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 64 | 18 | const viewer = getViewer(context); | |
| 65 | − | const role = roleIn(viewer, params.owner); | |
| 66 | − | // Who is in a workspace is its members' business; anyone else, including | |
| 67 | − | // someone a repository is shared with, gets nothing here. | |
| 68 | − | if (!role) throw data(null, { status: 404 }); | |
| 69 | − | const owner = role === "owner"; | |
| 70 | − | const [members, invites, workspace, outside, teams, free, mode] = await Promise.all([ | |
| 71 | − | identity.listMembers(params.owner, viewer), | |
| 72 | − | owner ? identity.workspaceInvites(params.owner, viewer).catch(() => null) : null, | |
| 73 | − | identity.getWorkspace(params.owner), | |
| 74 | − | owner ? identity.outsideCollaborators(viewer, params.owner).catch(() => null) : null, | |
| 75 | − | // Each member's teams, as the viewer may see them. | |
| 76 | − | identity.teamMemberships(viewer, params.owner).catch(() => null), | |
| 77 | − | // A free workspace adds no one until it starts the plan; identity | |
| 78 | − | // refuses it either way, so a failure here only hides the note. | |
| 79 | − | billing.freeWorkspaces([params.owner]).catch(() => [] as string[]), | |
| 80 | − | // Whether sign-up takes an invite: the invite form says what that means. | |
| 81 | − | registrationMode(), | |
| 19 | + | // Who is in a workspace is its members' business. | |
| 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), | |
| 82 | 24 | ]); | |
| 83 | 25 | return { | |
| 84 | − | role, | |
| 85 | − | name: workspace?.name?.trim() || params.owner, | |
| 86 | − | inviteOnly: mode !== "open", | |
| 87 | − | free: free.includes(params.owner.toLowerCase()), | |
| 88 | − | members: unwrap(members), | |
| 89 | − | invites: invites?.ok ? invites.value : [], | |
| 90 | − | base: workspace?.basePermission ?? DEFAULT_BASE_PERMISSION, | |
| 91 | − | outside: outside?.ok ? outside.value : [], | |
| 92 | − | teams: Object.fromEntries((teams?.ok ? teams.value : []).map((person) => [person.username, person.teams])), | |
| 93 | − | origin: new URL(request.url).origin, | |
| 94 | − | me: viewer?.username ?? null, | |
| 26 | + | slug: params.owner.toLowerCase(), | |
| 27 | + | directory, | |
| 28 | + | agents: agents?.ok ? agents.value.map(peopleAgent) : [], | |
| 95 | 29 | }; | |
| 96 | 30 | } | |
| 97 | 31 | ||
| 98 | − | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 99 | − | assertSameOrigin(request); | |
| 100 | − | const user = requireUser(context, request); | |
| 101 | − | const form = await request.formData(); | |
| 102 | − | if (form.get("action") === "revoke-invite") { | |
| 103 | − | const result = await identity.revokeWorkspaceInvite(user, params.owner, String(form.get("id") ?? "")); | |
| 104 | − | return result.ok ? null : { error: result.error.message }; | |
| 105 | − | } | |
| 106 | − | const target = String(form.get("member") ?? "").trim(); | |
| 107 | − | // Owner or member, and the roles that add to a member. | |
| 108 | − | if (form.get("action") === "role") { | |
| 109 | − | const role = form.get("role") === "owner" ? "owner" : "member"; | |
| 110 | − | const result = await identity.updateMember(user, params.owner, target, { role }); | |
| 111 | − | return result.ok ? { changed: target } : { error: result.error.message, row: target }; | |
| 112 | − | } | |
| 113 | − | if (form.get("action") === "org-roles") { | |
| 114 | − | const roles = String(form.get("org_roles") ?? "") | |
| 115 | − | .split(",") | |
| 116 | − | .filter((role): role is OrgRole => (ORG_ROLES as readonly string[]).includes(role)); | |
| 117 | − | const result = await identity.updateMember(user, params.owner, target, { org_roles: roles }); | |
| 118 | − | return result.ok ? { changed: target } : { error: result.error.message, row: target }; | |
| 119 | − | } | |
| 120 | − | if (form.get("action") === "transfer") { | |
| 121 | − | const result = await identity.transferOwnership(user, params.owner, target); | |
| 122 | − | return result.ok ? { transferred: target } : { error: result.error.message, row: target }; | |
| 123 | − | } | |
| 124 | − | if (form.get("action") === "leave") { | |
| 125 | − | const result = await identity.leaveWorkspace(user, params.owner); | |
| 126 | − | if (!result.ok) return { error: result.error.message, leaving: true }; | |
| 127 | − | const secure = new URL(request.url).protocol === "https:"; | |
| 128 | − | throw redirect("/", { headers: { "Set-Cookie": forgetWorkspace(secure) } }); | |
| 129 | − | } | |
| 130 | − | if (form.get("action") === "base-permission") { | |
| 131 | − | const base = String(form.get("base") ?? ""); | |
| 132 | − | if (!(BASE_PERMISSIONS as readonly string[]).includes(base)) return { error: "Choose a base permission." }; | |
| 133 | − | const result = await identity.setBasePermission(user, params.owner, base as BasePermission); | |
| 134 | − | return result.ok ? { based: result.value } : { error: result.error.message, base: true }; | |
| 135 | − | } | |
| 136 | − | const member = String(form.get("member") ?? "").trim(); | |
| 137 | − | if (form.get("action") === "remove") { | |
| 138 | − | const result = await identity.removeMember(user, params.owner, member); | |
| 139 | − | return result.ok ? null : { error: result.error.message, converting: false }; | |
| 140 | − | } | |
| 141 | − | // Nobody joins without saying yes: a username or an address gets an | |
| 142 | − | // invitation to accept or decline, with the role chosen here. | |
| 143 | − | const who = inviteTarget(member); | |
| 144 | − | if (!who) return { error: "Enter a g1t username or an email address.", outOfInvites: false }; | |
| 145 | − | const role = form.get("role") === "owner" ? "owner" : "member"; | |
| 146 | − | const result = await identity.inviteMember(user, params.owner, { ...who, role }); | |
| 147 | − | if (!result.ok) { | |
| 148 | − | return { error: result.error.message, outOfInvites: result.error.code === "limit", converting: form.get("action") === "convert" }; | |
| 149 | − | } | |
| 150 | − | if (form.get("action") === "convert") return { converted: member }; | |
| 151 | − | return { invited: "email" in who ? who.email : `@${"username" in who ? who.username : member}`, outOfInvites: false }; | |
| 32 | + | /** Searching changes only the address: everyone is already here. */ | |
| 33 | + | export function shouldRevalidate({ currentUrl, nextUrl, formMethod, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) { | |
| 34 | + | if (!formMethod && currentUrl.pathname === nextUrl.pathname) return false; | |
| 35 | + | return defaultShouldRevalidate; | |
| 152 | 36 | } | |
| 153 | 37 | ||
| 154 | − | /** What the base permission means for members, in a sentence. */ | |
| 155 | − | const BASE_MEANS: Record<BasePermission, string> = { | |
| 156 | − | none: "Members see only public repositories and the ones they are given a role on.", | |
| 157 | − | read: "Members can see and clone every repository, open issues and pull requests, and comment.", | |
| 158 | − | write: "Members can see every repository, manage its issues and pull requests, push, merge, and put agents to work.", | |
| 159 | − | admin: "Members can do everything on every repository, including its settings, webhooks, secrets and who has access. Only owners transfer or delete one.", | |
| 160 | − | }; | |
| 161 | − | ||
| 162 | − | /** What a member's roles say about them, as badges: owner or member, then the roles on top. */ | |
| 163 | − | function RoleBadges({ member }: { member: Member }) { | |
| 164 | − | return ( | |
| 165 | − | <span className="flex flex-wrap justify-end gap-1"> | |
| 166 | − | {member.role === "owner" ? <Badge tone="accent">Owner</Badge> : <Badge>Member</Badge>} | |
| 167 | − | {(member.org_roles ?? []).map((role) => ( | |
| 168 | − | <Hint key={role} label={ORG_ROLE_SUMMARIES[role]}> | |
| 169 | − | <Badge tone="info" tabIndex={0}> | |
| 170 | − | {ORG_ROLE_LABELS[role]} | |
| 171 | − | </Badge> | |
| 172 | − | </Hint> | |
| 173 | − | ))} | |
| 174 | − | </span> | |
| 175 | − | ); | |
| 176 | − | } | |
| 38 | + | const KINDS: { value: DirectoryKind; label: string }[] = [ | |
| 39 | + | { value: "everyone", label: "Everyone" }, | |
| 40 | + | { value: "people", label: "People" }, | |
| 41 | + | { value: "agents", label: "Agents" }, | |
| 42 | + | ]; | |
| 177 | 43 | ||
| 178 | − | /** Whether a member has two-factor authentication on, for owners. */ | |
| 179 | − | function TwoFactorMark({ on }: { on: boolean | null | undefined }) { | |
| 180 | − | if (on == null) return null; | |
| 181 | − | return on ? ( | |
| 182 | − | <Hint label="Two-factor authentication is on"> | |
| 183 | − | <span tabIndex={0} className="inline-flex text-success" aria-label="Two-factor authentication on"> | |
| 184 | − | <ShieldCheck size={15} /> | |
| 185 | − | </span> | |
| 186 | − | </Hint> | |
| 187 | − | ) : ( | |
| 188 | − | <Hint label="Two-factor authentication is off"> | |
| 189 | − | <span tabIndex={0} className="inline-flex text-warn" aria-label="Two-factor authentication off"> | |
| 190 | − | <ShieldAlert size={15} /> | |
| 191 | − | </span> | |
| 192 | − | </Hint> | |
| 193 | − | ); | |
| 194 | − | } | |
| 44 | + | export default function PeopleDirectoryPage({ loaderData }: Route.ComponentProps) { | |
| 45 | + | const { slug, directory, agents } = loaderData; | |
| 46 | + | const [params, setParams] = useSearchParams(); | |
| 47 | + | const query = params.get("q") ?? ""; | |
| 48 | + | const kind = directoryKind(params.get("kind")); | |
| 49 | + | const entries = directoryEntries(directory, agents, query, kind); | |
| 50 | + | const change = (key: string, value: string) => | |
| 51 | + | setParams( | |
| 52 | + | (current) => { | |
| 53 | + | const next = new URLSearchParams(current); | |
| 54 | + | if (value) next.set(key, value); | |
| 55 | + | else next.delete(key); | |
| 56 | + | return next; | |
| 57 | + | }, | |
| 58 | + | { replace: true, preventScrollReset: true }, | |
| 59 | + | ); | |
| 60 | + | const counts = { everyone: directory.people.length + agents.length, people: directory.people.length, agents: agents.length }; | |
| 195 | 61 | ||
| 196 | − | /** An owner's menu for one member: their role, the roles on top, handing over and removing. */ | |
| 197 | − | function MemberMenu({ member, self, owners, slug }: { member: Member; self: boolean; owners: number; slug: string }) { | |
| 198 | − | const submit = useSubmit(); | |
| 199 | − | const [confirm, setConfirm] = useState<"transfer" | "remove" | null>(null); | |
| 200 | − | const roles = member.org_roles ?? []; | |
| 201 | − | const post = (fields: Record<string, string>) => | |
| 202 | − | submit({ ...fields, member: member.username }, { method: "post", preventScrollReset: true }); | |
| 203 | − | const lastOwner = member.role === "owner" && owners <= 1; | |
| 204 | 62 | return ( | |
| 205 | − | <> | |
| 206 | − | <DropdownMenu> | |
| 207 | − | <DropdownMenuTrigger asChild> | |
| 208 | − | <button | |
| 209 | − | type="button" | |
| 210 | − | aria-label={`Manage ${member.username}`} | |
| 211 | − | className="rounded-md p-1.5 text-faint transition-colors max-sm:p-2.5 hover:bg-raised hover:text-fg" | |
| 212 | − | > | |
| 213 | − | <Ellipsis size={16} /> | |
| 214 | − | </button> | |
| 215 | − | </DropdownMenuTrigger> | |
| 216 | − | <DropdownMenuContent align="end" className="w-64"> | |
| 217 | − | <DropdownMenuLabel>Role in {slug}</DropdownMenuLabel> | |
| 218 | − | {member.role === "member" ? ( | |
| 219 | − | <DropdownMenuItem onSelect={() => post({ action: "role", role: "owner" })}>Make owner</DropdownMenuItem> | |
| 220 | − | ) : ( | |
| 221 | − | <DropdownMenuItem disabled={lastOwner} onSelect={() => post({ action: "role", role: "member" })}> | |
| 222 | − | {lastOwner ? "The only owner" : "Make member"} | |
| 223 | − | </DropdownMenuItem> | |
| 63 | + | <div className="space-y-6"> | |
| 64 | + | <div className="flex flex-col gap-3 md:flex-row md:items-center"> | |
| 65 | + | <label className="relative block grow"> | |
| 66 | + | <span className="sr-only">Search people and agents</span> | |
| 67 | + | <Search size={15} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" /> | |
| 68 | + | <input | |
| 69 | + | type="search" | |
| 70 | + | value={query} | |
| 71 | + | onChange={(event) => change("q", event.target.value)} | |
| 72 | + | placeholder="Name, title, team or what they own" | |
| 73 | + | {...notACredential()} | |
| 74 | + | className="w-full rounded-lg border border-line bg-bg py-2 pr-3 pl-9 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim" | |
| 75 | + | /> | |
| 76 | + | </label> | |
| 77 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 78 | + | <div role="group" aria-label="Show" className="inline-flex rounded-lg border border-line bg-bg p-0.5"> | |
| 79 | + | {KINDS.map((option) => ( | |
| 80 | + | <button | |
| 81 | + | key={option.value} | |
| 82 | + | type="button" | |
| 83 | + | aria-pressed={kind === option.value} | |
| 84 | + | onClick={() => change("kind", option.value === "everyone" ? "" : option.value)} | |
| 85 | + | className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${ | |
| 86 | + | kind === option.value ? "bg-raised text-fg" : "text-muted hover:text-fg" | |
| 87 | + | }`} | |
| 88 | + | > | |
| 89 | + | {option.label} <span className="text-faint tabular-nums">{counts[option.value]}</span> | |
| 90 | + | </button> | |
| 91 | + | ))} | |
| 92 | + | </div> | |
| 93 | + | <ButtonLink to={`/${slug}/-/org-chart`} variant="quiet"> | |
| 94 | + | <Network size={15} /> | |
| 95 | + | Org chart | |
| 96 | + | </ButtonLink> | |
| 97 | + | {directory.can_manage && ( | |
| 98 | + | <ButtonLink to={`/${slug}/-/members`}> | |
| 99 | + | <UserPlus size={15} /> | |
| 100 | + | Invite people | |
| 101 | + | </ButtonLink> | |
| 224 | 102 | )} | |
| 225 | − | <DropdownMenuSeparator /> | |
| 226 | − | <DropdownMenuLabel>Also</DropdownMenuLabel> | |
| 227 | − | {ORG_ROLES.map((role) => { | |
| 228 | − | const has = roles.includes(role); | |
| 229 | − | const next = has ? roles.filter((other) => other !== role) : [...roles, role]; | |
| 230 | − | return ( | |
| 231 | − | <DropdownMenuItem key={role} onSelect={() => post({ action: "org-roles", org_roles: next.join(",") })}> | |
| 232 | − | <span className="flex size-4 items-center justify-center">{has && <Check />}</span> | |
| 233 | − | {ORG_ROLE_LABELS[role]} | |
| 234 | − | </DropdownMenuItem> | |
| 235 | − | ); | |
| 236 | − | })} | |
| 237 | − | {!self && ( | |
| 103 | + | </div> | |
| 104 | + | </div> | |
| 105 | + | ||
| 106 | + | {entries.length === 0 ? ( | |
| 107 | + | <p className="rounded-xl border border-dashed border-line px-4 py-10 text-center text-sm text-muted"> | |
| 108 | + | {query ? ( | |
| 238 | 109 | <> | |
| 239 | − | <DropdownMenuSeparator /> | |
| 240 | − | {member.role === "member" && ( | |
| 241 | − | <DropdownMenuItem onSelect={() => setConfirm("transfer")}>Transfer ownership…</DropdownMenuItem> | |
| 242 | − | )} | |
| 243 | − | <DropdownMenuItem disabled={lastOwner} onSelect={() => setConfirm("remove")} className="text-danger"> | |
| 244 | − | Remove from {slug}… | |
| 245 | − | </DropdownMenuItem> | |
| 110 | + | Nobody matches “{query}”. Try a team, like “sales”, or a job, like “refunds”.{" "} | |
| 111 | + | <button type="button" className="text-accent hover:underline" onClick={() => change("q", "")}> | |
| 112 | + | Show everyone | |
| 113 | + | </button> | |
| 246 | 114 | </> | |
| 115 | + | ) : ( | |
| 116 | + | "No one here yet." | |
| 247 | 117 | )} | |
| 248 | − | </DropdownMenuContent> | |
| 249 | − | </DropdownMenu> | |
| 250 | − | <AlertDialog open={confirm !== null} onOpenChange={(open) => !open && setConfirm(null)}> | |
| 251 | − | <AlertDialogContent> | |
| 252 | − | <Form method="post" className="grid gap-4" onSubmit={() => setConfirm(null)}> | |
| 253 | − | <input type="hidden" name="action" value={confirm === "transfer" ? "transfer" : "remove"} /> | |
| 254 | − | <input type="hidden" name="member" value={member.username} /> | |
| 255 | − | <AlertDialogHeader> | |
| 256 | − | <AlertDialogTitle> | |
| 257 | − | {confirm === "transfer" ? `Hand ${slug} to ${member.username}?` : `Remove ${member.username} from ${slug}?`} | |
| 258 | − | </AlertDialogTitle> | |
| 259 | − | <AlertDialogDescription> | |
| 260 | − | {confirm === "transfer" | |
| 261 | − | ? `${member.username} becomes an owner and you a member. Only an owner can make you an owner again.` | |
| 262 | − | : `Their roles on ${slug}'s repositories and their place in its teams go too. To keep them on a repository, add them back to it as an outside collaborator.`} | |
| 263 | − | </AlertDialogDescription> | |
| 264 | − | </AlertDialogHeader> | |
| 265 | − | <AlertDialogFooter> | |
| 266 | − | <AlertDialogCancel type="button">Cancel</AlertDialogCancel> | |
| 267 | − | <SubmitButton variant="danger" match={{ action: confirm ?? "", member: member.username }} pending="Working…"> | |
| 268 | − | {confirm === "transfer" ? "Transfer ownership" : "Remove"} | |
| 269 | − | </SubmitButton> | |
| 270 | − | </AlertDialogFooter> | |
| 271 | − | </Form> | |
| 272 | − | </AlertDialogContent> | |
| 273 | − | </AlertDialog> | |
| 274 | − | </> | |
| 275 | − | ); | |
| 276 | − | } | |
| 277 | − | ||
| 278 | − | /** Leaving the workspace, for anyone in it. */ | |
| 279 | − | function LeaveSection({ slug, soleOwner, error }: { slug: string; soleOwner: boolean; error: string | null }) { | |
| 280 | − | const [open, setOpen] = useState(false); | |
| 281 | − | return ( | |
| 282 | − | <section id="leave" className="mt-10 scroll-mt-20 border-t border-line pt-8"> | |
| 283 | − | <h2 className="font-medium">Leave {slug}</h2> | |
| 284 | − | <p className="mt-1 text-sm text-muted"> | |
| 285 | − | {soleOwner | |
| 286 | − | ? `You are ${slug}'s only owner. Make another member an owner first, or delete the workspace in its settings.` | |
| 287 | − | : "Your roles on its repositories and your place in its teams go too. An owner can add you again."} | |
| 288 | − | </p> | |
| 289 | − | <div className="mt-4"> | |
| 290 | − | <Button variant="danger" type="button" disabled={soleOwner} onClick={() => setOpen(true)}> | |
| 291 | − | Leave {slug} | |
| 292 | − | </Button> | |
| 293 | − | </div> | |
| 294 | − | <ErrorText>{error}</ErrorText> | |
| 295 | − | <AlertDialog open={open} onOpenChange={setOpen}> | |
| 296 | − | <AlertDialogContent> | |
| 297 | − | <Form method="post" className="grid gap-4"> | |
| 298 | − | <input type="hidden" name="action" value="leave" /> | |
| 299 | − | <AlertDialogHeader> | |
| 300 | − | <AlertDialogTitle>Leave {slug}?</AlertDialogTitle> | |
| 301 | − | <AlertDialogDescription>You lose access to its private repositories at once.</AlertDialogDescription> | |
| 302 | − | </AlertDialogHeader> | |
| 303 | − | <AlertDialogFooter> | |
| 304 | − | <AlertDialogCancel type="button">Cancel</AlertDialogCancel> | |
| 305 | − | <SubmitButton variant="danger" match={{ action: "leave" }} pending="Leaving…"> | |
| 306 | − | Leave | |
| 307 | − | </SubmitButton> | |
| 308 | − | </AlertDialogFooter> | |
| 309 | − | </Form> | |
| 310 | − | </AlertDialogContent> | |
| 311 | − | </AlertDialog> | |
| 312 | − | </section> | |
| 313 | − | ); | |
| 314 | − | } | |
| 315 | − | ||
| 316 | − | export default function WorkspacePeople({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 317 | − | const { role, members, invites, origin, base, outside, teams, free, me, name, inviteOnly } = loaderData; | |
| 318 | − | const owner = role === "owner"; | |
| 319 | − | // The invitation to join this workspace, not an invite to g1t (Settings → Invites). | |
| 320 | − | const inviteCopy = workspaceInviteCopy(name, inviteOnly); | |
| 321 | − | const owners = members.filter((member) => member.role === "owner").length; | |
| 322 | − | const rowError = (username: string) => | |
| 323 | − | actionData && "row" in actionData && actionData.row === username ? (actionData.error ?? null) : null; | |
| 324 | − | // Waiting to be used, or used by someone still confirming their email: | |
| 325 | − | // either can be revoked. | |
| 326 | − | // Waiting for an answer to a workspace invitation, too. | |
| 327 | − | const pending = invites.filter( | |
| 328 | − | (invite) => invite.status === "pending" || invite.status === "awaiting_confirmation" || invite.status === "awaiting_answer", | |
| 329 | − | ); | |
| 330 | − | const [search, setSearch] = useSearchParams(); | |
| 331 | − | const tab = search.get("tab") === "outside" && owner ? "outside" : "members"; | |
| 332 | − | const membersTab = ( | |
| 333 | − | <> | |
| 334 | − | <ul className="divide-y divide-line rounded-xl border border-line"> | |
| 335 | − | {members.map((member) => ( | |
| 336 | − | <li key={member.username} className="flex flex-wrap items-center gap-3 px-4 py-3"> | |
| 337 | − | <WithPresence person={{ username: member.username }} size={28}> | |
| 338 | − | <Avatar name={member.username} image={member.avatar} size={28} /> | |
| 339 | − | </WithPresence> | |
| 340 | − | <div className="min-w-0 grow truncate"> | |
| 341 | − | <UserCard username={member.username}> | |
| 342 | − | <Link to={`/u/${member.username}`} className="font-mono text-sm hover:text-accent"> | |
| 343 | − | {member.username} | |
| 344 | − | </Link> | |
| 345 | − | </UserCard> | |
| 346 | − | <PersonStatusEmoji person={{ username: member.username }} size={13} className="ml-1.5 align-[-2px]" /> | |
| 347 | − | {member.name && <span className="ml-2 hidden text-sm text-muted sm:inline">{member.name}</span>} | |
| 348 | − | {(teams[member.username] ?? []).length > 0 && ( | |
| 349 | − | <div className="mt-1 flex flex-wrap gap-1"> | |
| 350 | − | {(teams[member.username] ?? []).slice(0, 3).map((team) => ( | |
| 351 | − | <Link | |
| 352 | − | key={team.slug} | |
| 353 | − | to={`/${params.owner}/-/teams/${team.slug}`} | |
| 354 | − | className="rounded-full border border-line px-2 py-px text-xs text-muted transition-colors hover:border-line-strong hover:text-fg" | |
| 355 | − | > | |
| 356 | − | {team.name} | |
| 357 | − | </Link> | |
| 358 | − | ))} | |
| 359 | − | {(teams[member.username] ?? []).length > 3 && ( | |
| 360 | − | <span className="px-1 text-xs text-faint">+{(teams[member.username] ?? []).length - 3} more</span> | |
| 361 | − | )} | |
| 362 | − | </div> | |
| 363 | − | )} | |
| 364 | − | </div> | |
| 365 | − | {/* Together, so on a narrow screen they move under the name as one. */} | |
| 366 | − | <div className="ml-auto flex items-center gap-2"> | |
| 367 | − | {owner && <TwoFactorMark on={member.two_factor} />} | |
| 368 | − | <RoleBadges member={member} /> | |
| 369 | − | {owner && <MemberMenu member={member} self={member.username === me} owners={owners} slug={params.owner} />} | |
| 370 | − | </div> | |
| 371 | − | {rowError(member.username) && ( | |
| 372 | − | <p className="basis-full text-sm text-danger" role="alert"> | |
| 373 | − | {rowError(member.username)} | |
| 374 | − | </p> | |
| 375 | − | )} | |
| 376 | − | </li> | |
| 377 | − | ))} | |
| 378 | − | </ul> | |
| 379 | − | {owner && free && ( | |
| 380 | − | <div className="mt-6"> | |
| 381 | − | <StartPlanToInvite workspace={params.owner} owner={owner} /> | |
| 382 | − | </div> | |
| 383 | − | )} | |
| 384 | − | {owner && !free && ( | |
| 385 | − | // Empty again once the person is on the list; kept as typed when it failed. | |
| 386 | − | <section aria-labelledby="invite-to-workspace" className="mt-6"> | |
| 387 | − | <h2 id="invite-to-workspace" className="text-sm font-medium"> | |
| 388 | − | {inviteCopy.heading} | |
| 389 | − | </h2> | |
| 390 | − | <p className="mt-1 text-xs text-faint">{inviteCopy.hint}</p> | |
| 391 | − | <Form | |
| 392 | − | method="post" | |
| 393 | − | key={`${members.length}:${pending.length}`} | |
| 394 | − | className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-start" | |
| 395 | − | > | |
| 396 | − | <input type="hidden" name="action" value="add" /> | |
| 397 | − | <div className="grow"> | |
| 398 | − | <Field label="Who"> | |
| 399 | − | <PeoplePicker name="member" placeholder="username, name or name@example.com" /> | |
| 400 | − | </Field> | |
| 401 | − | </div> | |
| 402 | − | <label className="block"> | |
| 403 | − | <span className="mb-1.5 block text-sm font-medium text-muted">Role</span> | |
| 404 | − | <SelectField | |
| 405 | − | name="role" | |
| 406 | − | defaultValue="member" | |
| 407 | − | className="h-auto w-full py-2 sm:w-auto sm:min-w-32" | |
| 408 | − | options={[ | |
| 409 | − | { value: "member", label: "Member" }, | |
| 410 | − | { value: "owner", label: "Owner" }, | |
| 411 | − | ]} | |
| 412 | − | /> | |
| 413 | − | </label> | |
| 414 | − | <div className="sm:pt-[1.625rem]"> | |
| 415 | − | <SubmitButton match={{ action: "add" }} pending="Inviting…"> | |
| 416 | − | Invite | |
| 417 | − | </SubmitButton> | |
| 418 | − | </div> | |
| 419 | − | </Form> | |
| 420 | − | {inviteCopy.elsewhere && ( | |
| 421 | − | <p className="mt-2 text-xs text-faint"> | |
| 422 | − | {inviteCopy.elsewhere.replace(/Settings → Invites\.$/, "")} | |
| 423 | − | <Link to="/settings/invites" className="text-muted underline underline-offset-4 hover:text-fg"> | |
| 424 | − | Settings → Invites | |
| 425 | − | </Link> | |
| 426 | − | . | |
| 427 | − | </p> | |
| 428 | − | )} | |
| 429 | − | </section> | |
| 430 | − | )} | |
| 431 | − | {actionData && "invited" in actionData && actionData.invited && ( | |
| 432 | − | <p className="text-sm text-muted" role="status"> | |
| 433 | − | Invitation to join {name} sent to <span className="text-fg">{actionData.invited}</span>. They join once they accept. | |
| 434 | − | </p> | |
| 435 | − | )} | |
| 436 | − | {actionData && "transferred" in actionData && ( | |
| 437 | − | <p className="text-sm text-muted" role="status"> | |
| 438 | − | <span className="font-mono text-fg">{actionData.transferred}</span> owns {params.owner} now, and you are a member. | |
| 439 | − | </p> | |
| 440 | − | )} | |
| 441 | − | {actionData && "converted" in actionData && ( | |
| 442 | − | <p className="text-sm text-muted" role="status"> | |
| 443 | − | <span className="font-mono text-fg">{actionData.converted}</span> is invited to join {params.owner} as a member, and joins once they accept. | |
| 444 | − | </p> | |
| 445 | − | )} | |
| 446 | − | {actionData && | |
| 447 | − | !("base" in actionData) && | |
| 448 | − | !("row" in actionData) && | |
| 449 | − | !("leaving" in actionData) && | |
| 450 | − | !("converting" in actionData && actionData.converting) && ( | |
| 451 | − | <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText> | |
| 452 | − | )} | |
| 453 | − | {actionData && "outOfInvites" in actionData && actionData.outOfInvites && ( | |
| 454 | − | <p className="mt-1 text-sm text-muted"> | |
| 455 | − | <a href={moreInvitesMailto(params.owner)} className="text-accent underline underline-offset-4"> | |
| 456 | − | Ask for more invites | |
| 457 | − | </a>{" "} | |
| 458 | − | for {params.owner}. | |
| 459 | 118 | </p> | |
| 460 | − | )} | |
| 461 | − | ||
| 462 | − | {owner && pending.length > 0 && ( | |
| 463 | − | <section className="mt-8"> | |
| 464 | − | <h2 className="text-sm font-medium">Pending invitations</h2> | |
| 465 | − | <ul className="mt-3 divide-y divide-line rounded-xl border border-line"> | |
| 466 | − | {pending.map((invite) => ( | |
| 467 | − | <li key={invite.id} className="space-y-2 px-4 py-3"> | |
| 468 | − | <div className="flex flex-wrap items-center gap-3"> | |
| 469 | − | <span className="min-w-0 grow truncate text-sm"> | |
| 470 | − | {invite.email ?? | |
| 471 | − | (invite.invitee ? ( | |
| 472 | − | <UserCard username={invite.invitee}> | |
| 473 | − | <Link to={`/u/${invite.invitee}`} className="font-mono hover:text-accent"> | |
| 474 | − | @{invite.invitee} | |
| 475 | − | </Link> | |
| 476 | − | </UserCard> | |
| 477 | − | ) : ( | |
| 478 | − | "Anyone with the link" | |
| 479 | − | ))} | |
| 480 | − | </span> | |
| 481 | − | {invite.role === "owner" && <Pill>Owner</Pill>} | |
| 482 | − | <Pill>{inviteState(invite).label}</Pill> | |
| 483 | − | <Form method="post"> | |
| 484 | − | <input type="hidden" name="action" value="revoke-invite" /> | |
| 485 | − | <input type="hidden" name="id" value={invite.id} /> | |
| 486 | − | <SubmitButton variant="quiet" match={{ action: "revoke-invite", id: invite.id }} pending="Revoking…"> | |
| 487 | − | Revoke | |
| 488 | − | </SubmitButton> | |
| 489 | − | </Form> | |
| 490 | − | </div> | |
| 491 | − | <p className="text-xs text-faint"> | |
| 492 | − | By {invite.invitedBy ?? "g1t"} · <TimeAgo at={invite.createdAt} /> · works until{" "} | |
| 493 | − | {new Date(invite.expiresAt).toISOString().slice(0, 10)} | |
| 494 | − | </p> | |
| 495 | − | {invite.code && <CopyLine text={inviteLink(invite.code, origin)} />} | |
| 496 | − | </li> | |
| 497 | − | ))} | |
| 498 | − | </ul> | |
| 499 | − | </section> | |
| 500 | − | )} | |
| 501 | − | ||
| 502 | − | <BasePermissionSection | |
| 503 | − | // Starts from the saved choice whenever it changes. | |
| 504 | − | key={base} | |
| 505 | − | base={base} | |
| 506 | − | owner={owner} | |
| 507 | − | slug={params.owner} | |
| 508 | − | error={actionData && "base" in actionData && "error" in actionData ? (actionData.error ?? null) : null} | |
| 509 | − | saved={Boolean(actionData && "based" in actionData)} | |
| 510 | − | /> | |
| 511 | − | ||
| 512 | − | <LeaveSection | |
| 513 | − | slug={params.owner} | |
| 514 | − | soleOwner={owner && owners <= 1} | |
| 515 | − | error={actionData && "leaving" in actionData ? (actionData.error ?? null) : null} | |
| 516 | − | /> | |
| 517 | − | </> | |
| 518 | − | ); | |
| 519 | − | return ( | |
| 520 | − | <div className="max-w-2xl"> | |
| 521 | − | {owner ? ( | |
| 522 | − | <Tabs | |
| 523 | − | value={tab} | |
| 524 | − | onValueChange={(value) => { | |
| 525 | − | const next = new URLSearchParams(search); | |
| 526 | − | if (value === "outside") next.set("tab", "outside"); | |
| 527 | − | else next.delete("tab"); | |
| 528 | − | setSearch(next, { replace: true, preventScrollReset: true }); | |
| 529 | − | }} | |
| 530 | − | > | |
| 531 | − | <TabsList className="mb-3"> | |
| 532 | − | <TabsTrigger value="members">Members · {members.length}</TabsTrigger> | |
| 533 | − | <TabsTrigger value="outside">Outside collaborators · {outside.length}</TabsTrigger> | |
| 534 | − | </TabsList> | |
| 535 | − | <TabsContent value="members">{membersTab}</TabsContent> | |
| 536 | − | <TabsContent value="outside"> | |
| 537 | − | <OutsideCollaborators | |
| 538 | − | people={outside} | |
| 539 | − | slug={params.owner} | |
| 540 | − | free={free} | |
| 541 | − | error={actionData && "converting" in actionData && actionData.converting ? actionData.error : null} | |
| 542 | − | /> | |
| 543 | − | </TabsContent> | |
| 544 | − | </Tabs> | |
| 545 | 119 | ) : ( | |
| 546 | − | membersTab | |
| 120 | + | <ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| 121 | + | {entries.map((entry) => ( | |
| 122 | + | <li key={entry.kind === "person" ? `p:${entry.person.username}` : `a:${entry.agent.id}`}> | |
| 123 | + | <EntryCard slug={slug} entry={entry} /> | |
| 124 | + | </li> | |
| 125 | + | ))} | |
| 126 | + | </ul> | |
| 547 | 127 | )} | |
| 548 | 128 | </div> | |
| 549 | 129 | ); | |
| 550 | 130 | } | |
| 551 | 131 | ||
| 552 | − | /** What members get on every repository, which owners choose. */ | |
| 553 | − | function BasePermissionSection({ | |
| 554 | − | base, | |
| 555 | − | owner, | |
| 556 | − | slug, | |
| 557 | − | error, | |
| 558 | − | saved, | |
| 559 | − | }: { | |
| 560 | − | base: BasePermission; | |
| 561 | − | owner: boolean; | |
| 562 | − | slug: string; | |
| 563 | − | error: string | null; | |
| 564 | − | saved: boolean; | |
| 565 | − | }) { | |
| 566 | − | const [chosen, setChosen] = useState<BasePermission>(base); | |
| 567 | − | return ( | |
| 568 | − | <section id="base-permission" className="mt-10 scroll-mt-20 border-t border-line pt-8"> | |
| 569 | − | <h2 className="font-medium">Base permission</h2> | |
| 570 | − | <p className="mt-1 text-sm text-muted"> | |
| 571 | − | The role every member has on each of {slug}'s repositories. Owners are Admins on all of them, and a role given to | |
| 572 | − | someone on one repository adds to this; it never takes away. | |
| 573 | − | </p> | |
| 574 | − | {owner ? ( | |
| 575 | − | <Form method="post" className="mt-4 rounded-xl border border-line bg-surface p-4"> | |
| 576 | − | <input type="hidden" name="action" value="base-permission" /> | |
| 577 | − | <div className="flex flex-col gap-3 sm:flex-row sm:items-center"> | |
| 578 | − | <Select name="base" value={chosen} onValueChange={(value) => setChosen(value as BasePermission)}> | |
| 579 | − | <SelectTrigger aria-label="Base permission" className="sm:max-w-48"> | |
| 580 | − | <SelectValue /> | |
| 581 | − | </SelectTrigger> | |
| 582 | − | <SelectContent> | |
| 583 | − | {BASE_PERMISSIONS.map((value) => ( | |
| 584 | − | <SelectItem key={value} value={value} description={BASE_MEANS[value]}> | |
| 585 | − | {BASE_PERMISSION_LABELS[value]} | |
| 586 | − | </SelectItem> | |
| 587 | − | ))} | |
| 588 | − | </SelectContent> | |
| 589 | − | </Select> | |
| 590 | − | <SubmitButton variant="quiet" match={{ action: "base-permission" }} pending="Saving…" disabled={chosen === base}> | |
| 591 | − | Save | |
| 592 | − | </SubmitButton> | |
| 593 | − | {saved && chosen === base && <span className="text-sm text-muted">Saved.</span>} | |
| 594 | − | </div> | |
| 595 | − | <p className="mt-3 text-sm text-muted">{BASE_MEANS[chosen]}</p> | |
| 596 | − | <ErrorText>{error}</ErrorText> | |
| 597 | − | </Form> | |
| 598 | − | ) : ( | |
| 599 | − | <div className="mt-4 rounded-xl border border-line bg-surface p-4 text-sm"> | |
| 600 | − | <p> | |
| 601 | − | <span className="font-medium">{BASE_PERMISSION_LABELS[base]}.</span>{" "} | |
| 602 | − | <span className="text-muted">{BASE_MEANS[base]}</span> | |
| 603 | − | </p> | |
| 604 | − | <p className="mt-2 text-xs text-faint">Owners of the workspace choose it.</p> | |
| 605 | − | </div> | |
| 606 | − | )} | |
| 607 | − | </section> | |
| 608 | − | ); | |
| 609 | − | } | |
| 610 | − | ||
| 611 | − | /** People with a role on some of the workspace's repositories who are not members of it. */ | |
| 612 | − | function OutsideCollaborators({ | |
| 613 | − | people, | |
| 614 | − | slug, | |
| 615 | − | free, | |
| 616 | − | error, | |
| 617 | − | }: { | |
| 618 | − | people: Route.ComponentProps["loaderData"]["outside"]; | |
| 619 | − | slug: string; | |
| 620 | − | free: boolean; | |
| 621 | − | error: string | null | undefined; | |
| 622 | − | }) { | |
| 132 | + | /** One person or agent: who they are, their title, their teams and, for a person, their local time. */ | |
| 133 | + | function EntryCard({ slug, entry }: { slug: string; entry: DirectoryEntry }) { | |
| 134 | + | const teams = entry.teams.map((team) => team.name); | |
| 135 | + | if (entry.kind === "person") { | |
| 136 | + | const { person } = entry; | |
| 137 | + | return ( | |
| 138 | + | <Link | |
| 139 | + | to={personPath(slug, person.username)} | |
| 140 | + | prefetch="intent" | |
| 141 | + | className="flex h-full items-start gap-3 rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong" | |
| 142 | + | > | |
| 143 | + | <PersonFace person={person} size={44} ring="var(--color-surface)" /> | |
| 144 | + | <span className="min-w-0 grow"> | |
| 145 | + | <span className="flex items-center gap-1.5"> | |
| 146 | + | <span className="truncate font-medium">{personName(person)}</span> | |
| 147 | + | {person.role === "owner" && <Badge>Owner</Badge>} | |
| 148 | + | </span> | |
| 149 | + | <span className="block truncate text-sm text-muted">{person.title ?? `@${person.display_username ?? person.username}`}</span> | |
| 150 | + | <span className="mt-1.5 block truncate text-xs text-faint"> | |
| 151 | + | {teams.length ? teams.join(", ") : "No team"} | |
| 152 | + | <LocalTime zone={person.timezone} className="before:mx-1.5 before:content-['·']" /> | |
| 153 | + | </span> | |
| 154 | + | {person.owns.length > 0 && <span className="mt-1 block truncate text-xs text-fg-soft">Owns {person.owns.join(", ")}</span>} | |
| 155 | + | </span> | |
| 156 | + | </Link> | |
| 157 | + | ); | |
| 158 | + | } | |
| 159 | + | const { agent } = entry; | |
| 623 | 160 | return ( | |
| 624 | − | <> | |
| 625 | − | <p className="mb-4 text-sm text-muted"> | |
| 626 | − | People given a role on one or more of {slug}'s repositories without being members. They see only those | |
| 627 | − | repositories. Change or take away a role on the repository's Access settings. | |
| 628 | − | </p> | |
| 629 | − | {free && ( | |
| 630 | − | <div className="mb-4"> | |
| 631 | − | <StartPlanToInvite workspace={slug} owner /> | |
| 632 | − | </div> | |
| 633 | − | )} | |
| 634 | − | {people.length === 0 ? ( | |
| 635 | − | <div className="rounded-xl border border-dashed border-line px-6 py-10 text-center"> | |
| 636 | − | <Users size={18} className="mx-auto text-faint" /> | |
| 637 | − | <p className="mt-2 text-sm font-medium">No outside collaborators</p> | |
| 638 | − | <p className="mt-1 text-sm text-muted">Add someone to a single repository from its Settings → Access.</p> | |
| 639 | − | </div> | |
| 640 | − | ) : ( | |
| 641 | − | <ul className="divide-y divide-line rounded-xl border border-line"> | |
| 642 | − | {people.map((person) => ( | |
| 643 | − | <li key={person.username} className="px-4 py-3"> | |
| 644 | − | <div className="flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 645 | − | <Avatar name={person.username} image={person.avatar} size={28} /> | |
| 646 | − | <div className="min-w-0 grow basis-32"> | |
| 647 | − | <UserCard username={person.username}> | |
| 648 | − | <Link to={`/u/${person.username}`} className="font-mono text-sm hover:text-accent"> | |
| 649 | − | {person.username} | |
| 650 | − | </Link> | |
| 651 | − | </UserCard> | |
| 652 | − | {person.name && <span className="ml-2 text-sm text-muted">{person.name}</span>} | |
| 653 | − | </div> | |
| 654 | − | {!free && ( | |
| 655 | − | <Form method="post"> | |
| 656 | − | <input type="hidden" name="action" value="convert" /> | |
| 657 | − | <input type="hidden" name="member" value={person.username} /> | |
| 658 | − | <SubmitButton variant="quiet" match={{ action: "convert", member: person.username }} pending="Inviting…"> | |
| 659 | − | Invite as a member | |
| 660 | − | </SubmitButton> | |
| 661 | − | </Form> | |
| 662 | − | )} | |
| 663 | − | </div> | |
| 664 | − | <ul className="mt-2 flex flex-wrap gap-1.5 pl-10"> | |
| 665 | − | {person.repos.map((grant) => ( | |
| 666 | − | <li key={grant.repo}> | |
| 667 | − | <Link | |
| 668 | − | to={`/${grant.repo}/settings/access`} | |
| 669 | − | className="group inline-flex items-center gap-1.5 rounded-full border border-line px-2 py-0.5 text-xs transition-colors hover:border-line-strong hover:bg-surface" | |
| 670 | − | > | |
| 671 | − | <span className="font-mono text-fg/90">{grant.repo}</span> | |
| 672 | − | <span className="text-muted">{REPO_ROLE_LABELS[grant.role]}</span> | |
| 673 | − | <ArrowUpRight size={11} className="text-faint group-hover:text-fg" /> | |
| 674 | − | </Link> | |
| 675 | − | </li> | |
| 676 | − | ))} | |
| 677 | − | </ul> | |
| 678 | − | </li> | |
| 679 | − | ))} | |
| 680 | − | </ul> | |
| 681 | − | )} | |
| 682 | − | <ErrorText>{error}</ErrorText> | |
| 683 | − | </> | |
| 161 | + | <Link | |
| 162 | + | to={agentPath(slug, agent.handle)} | |
| 163 | + | prefetch="intent" | |
| 164 | + | className="flex h-full items-start gap-3 rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong" | |
| 165 | + | > | |
| 166 | + | <AgentPersonFace agent={agent} size={44} ring="var(--color-surface)" /> | |
| 167 | + | <span className="min-w-0 grow"> | |
| 168 | + | <span className="flex items-center gap-1.5"> | |
| 169 | + | <span className="truncate font-medium">{agent.display_name}</span> | |
| 170 | + | <AgentTag /> | |
| 171 | + | </span> | |
| 172 | + | <span className="block truncate text-sm text-muted">{agent.title || agent.role}</span> | |
| 173 | + | <span className="mt-1.5 block truncate text-xs text-faint">{teams.length ? teams.join(", ") : "No team"}</span> | |
| 174 | + | {agent.responsibilities.length > 0 && <span className="mt-1 block truncate text-xs text-fg-soft">{agent.responsibilities.slice(0, 2).join(" · ")}</span>} | |
| 175 | + | </span> | |
| 176 | + | </Link> | |
| 684 | 177 | ); | |
| 685 | 178 | } |
| 1 | + | import { ArrowUpRight, Bot, ChevronRight, KeyRound, MessageSquare, Sparkles, UsersRound } from "lucide-react"; | |
| 2 | + | import type { ReactNode } from "react"; | |
| 3 | + | import { Link, data } from "react-router"; | |
| 4 | + | ||
| 5 | + | import type { Route } from "./+types/agent"; | |
| 6 | + | import { AgentLink, AgentPersonFace, AgentTag, Coming, PersonFace, PersonLink, TeamKindMark, ToldText } from "../../../components/people"; | |
| 7 | + | import { statusLabel } from "../../../components/chat/marks"; | |
| 8 | + | import { ButtonLink } from "../../../components/ui"; | |
| 9 | + | import { Badge } from "../../../components/ui/badge"; | |
| 10 | + | import { page } from "../../../lib/meta"; | |
| 11 | + | import { agentsOn, leads, peopleAgent, teamsOfAgent } from "../../../lib/people"; | |
| 12 | + | import { teamPath } from "../../../lib/teams"; | |
| 13 | + | import { money } from "../../../lib/usage"; | |
| 14 | + | import { identity, workspaceAgents } from "../../../lib/services.server"; | |
| 15 | + | import { getViewer, roleIn, unwrap } from "../../../lib/session.server"; | |
| 16 | + | ||
| 17 | + | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { | |
| 18 | + | return page(args, { title: `${loaderData?.agent.display_name ?? params.handle} · People · ${params.owner} · g1t` }); | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 22 | + | const viewer = getViewer(context); | |
| 23 | + | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 24 | + | const [found, directory, listed, told] = await Promise.all([ | |
| 25 | + | workspaceAgents.get(params.owner, params.handle, viewer!), | |
| 26 | + | identity.peopleDirectory(viewer, params.owner).then(unwrap), | |
| 27 | + | workspaceAgents.list(params.owner, viewer!).catch(() => null), | |
| 28 | + | workspaceAgents.teamContext(params.owner, params.handle, viewer!).catch(() => null), | |
| 29 | + | ]); | |
| 30 | + | if (!found.ok) throw data(null, { status: 404 }); | |
| 31 | + | const agent = found.value; | |
| 32 | + | const agents = listed?.ok ? listed.value.map(peopleAgent) : [peopleAgent(agent)]; | |
| 33 | + | const teams = teamsOfAgent(directory.teams, agent); | |
| 34 | + | // Everyone on its teams, each once, leads first. | |
| 35 | + | const names = new Set(teams.flatMap((team) => team.people.map((p) => p.username))); | |
| 36 | + | const leadNames = new Set(teams.flatMap((team) => (team.lead?.kind === "user" ? [team.lead.username] : []))); | |
| 37 | + | const teammates = directory.people | |
| 38 | + | .filter((person) => names.has(person.username)) | |
| 39 | + | .sort((a, b) => Number(leadNames.has(b.username)) - Number(leadNames.has(a.username))) | |
| 40 | + | .map(({ user_id, username, display_username, name, avatar, title, owns }) => ({ user_id, username, display_username, name, avatar, title, owns, lead: leadNames.has(username) })); | |
| 41 | + | return { | |
| 42 | + | slug: params.owner.toLowerCase(), | |
| 43 | + | agent: { ...peopleAgent(agent), monthly_micros: agent.budget.monthly_micros }, | |
| 44 | + | teams, | |
| 45 | + | teammates, | |
| 46 | + | agents, | |
| 47 | + | told: told?.ok ? told.value.text : null, | |
| 48 | + | }; | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | export default function AgentProfile({ loaderData }: Route.ComponentProps) { | |
| 52 | + | const { slug, agent, teams, teammates, agents, told } = loaderData; | |
| 53 | + | const otherAgents = teams | |
| 54 | + | .flatMap((team) => agentsOn(team, agents)) | |
| 55 | + | .filter((other, index, all) => other.id !== agent.id && all.findIndex((a) => a.id === other.id) === index); | |
| 56 | + | ||
| 57 | + | return ( | |
| 58 | + | <div className="space-y-8"> | |
| 59 | + | <nav aria-label="Breadcrumb" className="flex items-center gap-1 text-sm text-muted"> | |
| 60 | + | <Link to={`/${slug}/-/people?kind=agents`} className="hover:text-fg"> | |
| 61 | + | People | |
| 62 | + | </Link> | |
| 63 | + | <ChevronRight size={13} className="text-faint" /> | |
| 64 | + | <span className="truncate text-fg">{agent.display_name}</span> | |
| 65 | + | </nav> | |
| 66 | + | ||
| 67 | + | <header className="flex flex-col gap-5 border-b border-line pb-6 sm:flex-row sm:items-start"> | |
| 68 | + | <div className="self-start"> | |
| 69 | + | <AgentPersonFace agent={agent} size={72} /> | |
| 70 | + | </div> | |
| 71 | + | <div className="min-w-0 grow space-y-2"> | |
| 72 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 73 | + | <h1 className="text-2xl font-semibold tracking-tight">{agent.display_name}</h1> | |
| 74 | + | <AgentTag /> | |
| 75 | + | </div> | |
| 76 | + | <p className="text-sm text-muted">{[agent.title || agent.role, `@${agent.handle}`].join(" · ")}</p> | |
| 77 | + | <p className="text-sm text-muted">{statusLabel(agent.status)}</p> | |
| 78 | + | </div> | |
| 79 | + | <div className="flex shrink-0 flex-wrap gap-2"> | |
| 80 | + | <ButtonLink to={`/${slug}/-/chat?agent=${agent.handle}`}> | |
| 81 | + | <MessageSquare size={15} /> | |
| 82 | + | Message | |
| 83 | + | </ButtonLink> | |
| 84 | + | <ButtonLink to={`/${slug}/-/agents/${agent.handle}`} variant="quiet"> | |
| 85 | + | Sessions and settings | |
| 86 | + | <ArrowUpRight size={14} /> | |
| 87 | + | </ButtonLink> | |
| 88 | + | </div> | |
| 89 | + | </header> | |
| 90 | + | ||
| 91 | + | {agent.responsibilities.length > 0 && ( | |
| 92 | + | <section className="flex flex-wrap items-center gap-1.5"> | |
| 93 | + | <span className="mr-1 text-sm text-muted">Responsible for</span> | |
| 94 | + | {agent.responsibilities.map((duty) => ( | |
| 95 | + | <Badge key={duty} tone="info"> | |
| 96 | + | {duty} | |
| 97 | + | </Badge> | |
| 98 | + | ))} | |
| 99 | + | </section> | |
| 100 | + | )} | |
| 101 | + | ||
| 102 | + | <div className="grid gap-4 md:grid-cols-2"> | |
| 103 | + | <Card icon={<UsersRound size={14} />} title="Teams"> | |
| 104 | + | {teams.length ? ( | |
| 105 | + | <ul className="space-y-2"> | |
| 106 | + | {teams.map((team) => ( | |
| 107 | + | <li key={team.slug} className="flex flex-wrap items-center gap-2"> | |
| 108 | + | <TeamKindMark people={team.people.length} agents={agentsOn(team, agents).length} /> | |
| 109 | + | <Link to={teamPath(slug, team.slug)} className="font-medium hover:text-accent"> | |
| 110 | + | {team.name} | |
| 111 | + | </Link> | |
| 112 | + | {leads(team.lead, { id: agent.id }) && <Badge tone="accent">Lead</Badge>} | |
| 113 | + | {agent.team === team.slug && <Badge>Home team</Badge>} | |
| 114 | + | </li> | |
| 115 | + | ))} | |
| 116 | + | </ul> | |
| 117 | + | ) : ( | |
| 118 | + | <Quiet>Not on a team yet. Add it to one from the team's page.</Quiet> | |
| 119 | + | )} | |
| 120 | + | </Card> | |
| 121 | + | ||
| 122 | + | <Card icon={<Sparkles size={14} />} title="Who it works with"> | |
| 123 | + | {teammates.length ? ( | |
| 124 | + | <ul className="space-y-2"> | |
| 125 | + | {teammates.map((person) => ( | |
| 126 | + | <li key={person.username} className="flex min-w-0 items-center gap-2.5 text-sm"> | |
| 127 | + | <PersonFace person={person} size={24} ring="var(--color-surface)" /> | |
| 128 | + | <span className="min-w-0 grow truncate"> | |
| 129 | + | <PersonLink workspace={slug} person={person} className="font-medium" /> | |
| 130 | + | {person.title && <span className="text-muted"> · {person.title}</span>} | |
| 131 | + | </span> | |
| 132 | + | {person.lead && <Badge tone="accent">Lead</Badge>} | |
| 133 | + | </li> | |
| 134 | + | ))} | |
| 135 | + | </ul> | |
| 136 | + | ) : ( | |
| 137 | + | <Quiet>{teams.length ? "Its teams are agents only." : "No people yet."}</Quiet> | |
| 138 | + | )} | |
| 139 | + | </Card> | |
| 140 | + | ||
| 141 | + | <Card icon={<Bot size={14} />} title="Agents on its teams"> | |
| 142 | + | {otherAgents.length ? ( | |
| 143 | + | <ul className="space-y-2"> | |
| 144 | + | {otherAgents.map((other) => ( | |
| 145 | + | <li key={other.id} className="flex min-w-0 items-center gap-2.5 text-sm"> | |
| 146 | + | <AgentPersonFace agent={other} size={24} ring="var(--color-surface)" /> | |
| 147 | + | <span className="min-w-0 truncate"> | |
| 148 | + | <AgentLink workspace={slug} agent={other} className="font-medium" /> | |
| 149 | + | <span className="text-muted"> · {other.title || other.role}</span> | |
| 150 | + | </span> | |
| 151 | + | </li> | |
| 152 | + | ))} | |
| 153 | + | </ul> | |
| 154 | + | ) : ( | |
| 155 | + | <Quiet>No other agents on its teams.</Quiet> | |
| 156 | + | )} | |
| 157 | + | </Card> | |
| 158 | + | ||
| 159 | + | <Card icon={<KeyRound size={14} />} title="Access"> | |
| 160 | + | <dl className="space-y-3 text-sm"> | |
| 161 | + | <div> | |
| 162 | + | <dt className="text-xs text-faint">Code</dt> | |
| 163 | + | <dd className="mt-0.5">What the person who asks it can read, and only what everyone in the conversation can see</dd> | |
| 164 | + | </div> | |
| 165 | + | <div> | |
| 166 | + | <dt className="flex items-center gap-2 text-xs text-faint"> | |
| 167 | + | Storage <Coming /> | |
| 168 | + | </dt> | |
| 169 | + | <dd className="mt-0.5 text-muted">Which of the workspace's files it may read and write comes with Storage.</dd> | |
| 170 | + | </div> | |
| 171 | + | <div> | |
| 172 | + | <dt className="text-xs text-faint">Spent this month</dt> | |
| 173 | + | <dd className="mt-0.5 tabular-nums"> | |
| 174 | + | {money(agent.spent_month_micros)} | |
| 175 | + | {agent.monthly_micros != null && <span className="text-muted"> of {money(agent.monthly_micros)}</span>} | |
| 176 | + | </dd> | |
| 177 | + | </div> | |
| 178 | + | </dl> | |
| 179 | + | </Card> | |
| 180 | + | </div> | |
| 181 | + | ||
| 182 | + | <section className="rounded-xl border border-accent/40 bg-surface p-4"> | |
| 183 | + | <div className="mb-3 flex flex-wrap items-baseline justify-between gap-2"> | |
| 184 | + | <h2 className="flex items-center gap-2 text-sm font-medium"> | |
| 185 | + | <Sparkles size={14} className="text-accent" /> | |
| 186 | + | What {agent.display_name} knows about its teams | |
| 187 | + | </h2> | |
| 188 | + | <span className="text-xs text-faint">Told every turn, from the team pages and who's around now</span> | |
| 189 | + | </div> | |
| 190 | + | {told ? <ToldText text={told} /> : <Quiet>Nothing yet: it's on no visible team. Once it is, it's told who leads, who owns what and who to page.</Quiet>} | |
| 191 | + | </section> | |
| 192 | + | </div> | |
| 193 | + | ); | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | function Card({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) { | |
| 197 | + | return ( | |
| 198 | + | <section className="rounded-xl border border-line bg-surface p-4"> | |
| 199 | + | <h2 className="mb-3 flex items-center gap-2 text-sm font-medium text-muted"> | |
| 200 | + | <span className="text-faint">{icon}</span> | |
| 201 | + | {title} | |
| 202 | + | </h2> | |
| 203 | + | {children} | |
| 204 | + | </section> | |
| 205 | + | ); | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | function Quiet({ children }: { children: ReactNode }) { | |
| 209 | + | return <p className="text-sm text-faint">{children}</p>; | |
| 210 | + | } |
| 1 | + | import { Bot, ChevronRight, KeyRound, MapPin, MessageSquare, Network, Pencil, UsersRound, Wallet } from "lucide-react"; | |
| 2 | + | import { type ReactNode, useEffect, useState } from "react"; | |
| 3 | + | import { Form, Link, data, redirect, useNavigation } from "react-router"; | |
| 4 | + | ||
| 5 | + | import type { DirectoryPerson } from "@g1t/contracts"; | |
| 6 | + | ||
| 7 | + | import type { Route } from "./+types/person"; | |
| 8 | + | import { AgentLink, AgentPersonFace, Coming, LocalTime, PersonFace, PersonLink, TeamKindMark } from "../../../components/people"; | |
| 9 | + | import { PresenceSummary } from "../../../components/presence"; | |
| 10 | + | import { Button, ErrorText, Field, Input, SubmitButton, Textarea } from "../../../components/ui"; | |
| 11 | + | import { Badge } from "../../../components/ui/badge"; | |
| 12 | + | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../../components/ui/dialog"; | |
| 13 | + | import { SelectField } from "../../../components/ui/select"; | |
| 14 | + | import { channelPath } from "../../../lib/chat"; | |
| 15 | + | import { page } from "../../../lib/meta"; | |
| 16 | + | import { | |
| 17 | + | agentsOn, | |
| 18 | + | codeAccessWords, | |
| 19 | + | leads, | |
| 20 | + | managerChoices, | |
| 21 | + | ownsFromText, | |
| 22 | + | peopleAgent, | |
| 23 | + | personName, | |
| 24 | + | reportsOf, | |
| 25 | + | teamsOfPerson, | |
| 26 | + | } from "../../../lib/people"; | |
| 27 | + | import { teamPath } from "../../../lib/teams"; | |
| 28 | + | import { money } from "../../../lib/usage"; | |
| 29 | + | import { chat, identity, workspaceAgents } from "../../../lib/services.server"; | |
| 30 | + | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server"; | |
| 31 | + | ||
| 32 | + | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { | |
| 33 | + | const name = loaderData ? personName(loaderData.person) : params.username; | |
| 34 | + | return page(args, { title: `${name} · People · ${params.owner} · g1t` }); | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /** The few fields of everyone else a profile shows. */ | |
| 38 | + | function slim(person: DirectoryPerson) { | |
| 39 | + | const { user_id, username, display_username, name, avatar, title, manager } = person; | |
| 40 | + | return { user_id, username, display_username, name, avatar, title, manager }; | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 44 | + | const viewer = getViewer(context); | |
| 45 | + | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 46 | + | const [directory, agents, budgets] = await Promise.all([ | |
| 47 | + | identity.peopleDirectory(viewer, params.owner).then(unwrap), | |
| 48 | + | workspaceAgents.list(params.owner, viewer!).catch(() => null), | |
| 49 | + | // Owners see everyone's; anyone else only their own. | |
| 50 | + | workspaceAgents.personBudgets(params.owner, viewer!).catch(() => null), | |
| 51 | + | ]); | |
| 52 | + | const username = params.username.toLowerCase(); | |
| 53 | + | const person = directory.people.find((p) => p.username === username); | |
| 54 | + | if (!person) throw data(null, { status: 404 }); | |
| 55 | + | const all = agents?.ok ? agents.value.map(peopleAgent) : []; | |
| 56 | + | const teams = teamsOfPerson(directory.teams, username); | |
| 57 | + | const self = viewer?.username.toLowerCase() === username; | |
| 58 | + | const budget = budgets?.ok ? (budgets.value.people.find((p) => p.username === username) ?? null) : null; | |
| 59 | + | return { | |
| 60 | + | slug: params.owner.toLowerCase(), | |
| 61 | + | person, | |
| 62 | + | people: directory.people.map(slim), | |
| 63 | + | teams, | |
| 64 | + | agents: all, | |
| 65 | + | base: directory.base_permission, | |
| 66 | + | canManage: directory.can_manage, | |
| 67 | + | self, | |
| 68 | + | spend: budget && (self || directory.can_manage) ? { spent: budget.spent_micros, budget: budget.monthly_micros } : null, | |
| 69 | + | }; | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 73 | + | assertSameOrigin(request); | |
| 74 | + | const user = requireUser(context, request); | |
| 75 | + | const form = await request.formData(); | |
| 76 | + | const intent = String(form.get("intent") ?? ""); | |
| 77 | + | if (intent === "message") { | |
| 78 | + | const directory = unwrap(await identity.peopleDirectory(user, params.owner)); | |
| 79 | + | const person = directory.people.find((p) => p.username === params.username.toLowerCase()); | |
| 80 | + | if (!person) return { intent, error: "They are no longer a member here." }; | |
| 81 | + | const opened = await chat.openDm(params.owner, user, [{ kind: "user", id: person.user_id }]); | |
| 82 | + | if (!opened.ok) return { intent, error: opened.error.message }; | |
| 83 | + | throw redirect(channelPath(params.owner.toLowerCase(), opened.value)); | |
| 84 | + | } | |
| 85 | + | if (intent === "profile") { | |
| 86 | + | const changes: { title?: string; owns?: string[]; manager?: string } = { | |
| 87 | + | title: String(form.get("title") ?? ""), | |
| 88 | + | owns: ownsFromText(String(form.get("owns") ?? "")), | |
| 89 | + | }; | |
| 90 | + | if (form.has("manager")) { | |
| 91 | + | const manager = String(form.get("manager") ?? ""); | |
| 92 | + | changes.manager = manager === "none" ? "" : manager; | |
| 93 | + | } | |
| 94 | + | const saved = await identity.setMemberProfile(user, params.owner, params.username, changes); | |
| 95 | + | return saved.ok ? { intent, saved: true as const, error: null } : { intent, error: saved.error.message }; | |
| 96 | + | } | |
| 97 | + | return { intent, error: "Unknown request." }; | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | export default function PersonProfile({ loaderData, actionData }: Route.ComponentProps) { | |
| 101 | + | const { slug, person, people, teams, agents, base, canManage, self, spend } = loaderData; | |
| 102 | + | const manager = person.manager ? people.find((p) => p.username === person.manager) : null; | |
| 103 | + | const reports = reportsOf(people as DirectoryPerson[], person.username); | |
| 104 | + | const teamAgents = teams.flatMap((team) => agentsOn(team, agents).map((agent) => ({ agent, team }))); | |
| 105 | + | const seen = new Set<string>(); | |
| 106 | + | const uniqueAgents = teamAgents.filter(({ agent }) => (seen.has(agent.id) ? false : (seen.add(agent.id), true))); | |
| 107 | + | const access = codeAccessWords(person, base, teams); | |
| 108 | + | const mayEdit = self || canManage; | |
| 109 | + | const navigation = useNavigation(); | |
| 110 | + | const messaging = navigation.formData?.get("intent") === "message"; | |
| 111 | + | ||
| 112 | + | return ( | |
| 113 | + | <div className="space-y-8"> | |
| 114 | + | <nav aria-label="Breadcrumb" className="flex items-center gap-1 text-sm text-muted"> | |
| 115 | + | <Link to={`/${slug}/-/people`} className="hover:text-fg"> | |
| 116 | + | People | |
| 117 | + | </Link> | |
| 118 | + | <ChevronRight size={13} className="text-faint" /> | |
| 119 | + | <span className="truncate text-fg">{personName(person)}</span> | |
| 120 | + | </nav> | |
| 121 | + | ||
| 122 | + | <header className="flex flex-col gap-5 border-b border-line pb-6 sm:flex-row sm:items-start"> | |
| 123 | + | <div className="self-start"> | |
| 124 | + | <PersonFace person={person} size={72} /> | |
| 125 | + | </div> | |
| 126 | + | <div className="min-w-0 grow space-y-2"> | |
| 127 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 128 | + | <h1 className="text-2xl font-semibold tracking-tight">{personName(person)}</h1> | |
| 129 | + | {person.role === "owner" && <Badge tone="accent">Owner</Badge>} | |
| 130 | + | {self && <Badge>You</Badge>} | |
| 131 | + | </div> | |
| 132 | + | <p className="text-sm text-muted"> | |
| 133 | + | {[person.title, `@${person.display_username ?? person.username}`, person.pronouns].filter(Boolean).join(" · ")} | |
| 134 | + | </p> | |
| 135 | + | <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted"> | |
| 136 | + | <PresenceSummary person={{ id: person.user_id, username: person.username }} className="flex flex-wrap items-center gap-x-4 gap-y-1 space-y-0" /> | |
| 137 | + | {person.location && ( | |
| 138 | + | <span className="inline-flex items-center gap-1.5"> | |
| 139 | + | <MapPin size={13} className="text-faint" /> | |
| 140 | + | {person.location} | |
| 141 | + | </span> | |
| 142 | + | )} | |
| 143 | + | <LocalTime zone={person.timezone} /> | |
| 144 | + | </div> | |
| 145 | + | </div> | |
| 146 | + | <div className="flex shrink-0 flex-wrap gap-2"> | |
| 147 | + | {!self && ( | |
| 148 | + | <Form method="post"> | |
| 149 | + | <input type="hidden" name="intent" value="message" /> | |
| 150 | + | <SubmitButton pending="Opening…" match={{ intent: "message" }} disabled={messaging}> | |
| 151 | + | <MessageSquare size={15} /> | |
| 152 | + | Message | |
| 153 | + | </SubmitButton> | |
| 154 | + | </Form> | |
| 155 | + | )} | |
| 156 | + | {mayEdit && <EditProfile person={person} people={people as DirectoryPerson[]} canManage={canManage} actionData={actionData} />} | |
| 157 | + | </div> | |
| 158 | + | </header> | |
| 159 | + | {actionData?.intent === "message" && <ErrorText>{actionData.error}</ErrorText>} | |
| 160 | + | ||
| 161 | + | {(person.bio || person.owns.length > 0) && ( | |
| 162 | + | <section className="space-y-3"> | |
| 163 | + | {person.bio && <p className="max-w-2xl text-[0.9375rem] text-fg-soft">{person.bio}</p>} | |
| 164 | + | {person.owns.length > 0 && ( | |
| 165 | + | <div className="flex flex-wrap items-center gap-1.5"> | |
| 166 | + | <span className="mr-1 text-sm text-muted">Owns</span> | |
| 167 | + | {person.owns.map((thing) => ( | |
| 168 | + | <Badge key={thing} tone="info"> | |
| 169 | + | {thing} | |
| 170 | + | </Badge> | |
| 171 | + | ))} | |
| 172 | + | </div> | |
| 173 | + | )} | |
| 174 | + | </section> | |
| 175 | + | )} | |
| 176 | + | ||
| 177 | + | <div className="grid gap-4 md:grid-cols-2"> | |
| 178 | + | <Card icon={<UsersRound size={14} />} title="Teams"> | |
| 179 | + | {teams.length ? ( | |
| 180 | + | <ul className="space-y-2"> | |
| 181 | + | {teams.map((team) => { | |
| 182 | + | const role = team.people.find((p) => p.username === person.username)?.role; | |
| 183 | + | return ( | |
| 184 | + | <li key={team.slug} className="flex flex-wrap items-center gap-2"> | |
| 185 | + | <TeamKindMark people={team.people.length} agents={agentsOn(team, agents).length} /> | |
| 186 | + | <Link to={teamPath(slug, team.slug)} className="font-medium hover:text-accent"> | |
| 187 | + | {team.name} | |
| 188 | + | </Link> | |
| 189 | + | {leads(team.lead, { username: person.username }) && <Badge tone="accent">Lead</Badge>} | |
| 190 | + | {role === "maintainer" && <Badge>Maintainer</Badge>} | |
| 191 | + | </li> | |
| 192 | + | ); | |
| 193 | + | })} | |
| 194 | + | </ul> | |
| 195 | + | ) : ( | |
| 196 | + | <Quiet>Not on a team yet.</Quiet> | |
| 197 | + | )} | |
| 198 | + | </Card> | |
| 199 | + | ||
| 200 | + | <Card icon={<Network size={14} />} title="Reporting line"> | |
| 201 | + | <dl className="space-y-3 text-sm"> | |
| 202 | + | <div> | |
| 203 | + | <dt className="text-xs text-faint">Reports to</dt> | |
| 204 | + | <dd className="mt-1">{manager ? <PersonRow slug={slug} person={manager} /> : <Quiet>No one{canManage ? ". Set it under Edit profile." : "."}</Quiet>}</dd> | |
| 205 | + | </div> | |
| 206 | + | <div> | |
| 207 | + | <dt className="text-xs text-faint"> | |
| 208 | + | Reports <span className="tabular-nums">{reports.length}</span> | |
| 209 | + | </dt> | |
| 210 | + | <dd className="mt-1 space-y-1.5"> | |
| 211 | + | {reports.length ? reports.map((report) => <PersonRow key={report.username} slug={slug} person={report} />) : <Quiet>No one reports to them.</Quiet>} | |
| 212 | + | </dd> | |
| 213 | + | </div> | |
| 214 | + | </dl> | |
| 215 | + | </Card> | |
| 216 | + | ||
| 217 | + | <Card icon={<Bot size={14} />} title="Agents on their teams"> | |
| 218 | + | {uniqueAgents.length ? ( | |
| 219 | + | <ul className="space-y-2"> | |
| 220 | + | {uniqueAgents.map(({ agent, team }) => ( | |
| 221 | + | <li key={agent.id} className="flex items-center gap-2.5 text-sm"> | |
| 222 | + | <AgentPersonFace agent={agent} size={24} ring="var(--color-surface)" /> | |
| 223 | + | <span className="min-w-0 grow truncate"> | |
| 224 | + | <AgentLink workspace={slug} agent={agent} className="font-medium" /> | |
| 225 | + | <span className="text-muted"> · {agent.title || agent.role}</span> | |
| 226 | + | </span> | |
| 227 | + | <span className="shrink-0 text-xs text-faint">{team.name}</span> | |
| 228 | + | </li> | |
| 229 | + | ))} | |
| 230 | + | </ul> | |
| 231 | + | ) : ( | |
| 232 | + | <Quiet>{teams.length ? "Their teams are people only." : "None yet."}</Quiet> | |
| 233 | + | )} | |
| 234 | + | </Card> | |
| 235 | + | ||
| 236 | + | <Card icon={<KeyRound size={14} />} title="Access"> | |
| 237 | + | <dl className="space-y-3 text-sm"> | |
| 238 | + | <div> | |
| 239 | + | <dt className="text-xs text-faint">Code</dt> | |
| 240 | + | <dd className="mt-0.5">{access.summary}</dd> | |
| 241 | + | {access.through.map((line) => ( | |
| 242 | + | <dd key={line} className="text-muted"> | |
| 243 | + | {line} | |
| 244 | + | </dd> | |
| 245 | + | ))} | |
| 246 | + | </div> | |
| 247 | + | <div> | |
| 248 | + | <dt className="flex items-center gap-2 text-xs text-faint"> | |
| 249 | + | Storage <Coming /> | |
| 250 | + | </dt> | |
| 251 | + | <dd className="mt-0.5 text-muted">Who can read and write the workspace's files comes with Storage.</dd> | |
| 252 | + | </div> | |
| 253 | + | {spend && ( | |
| 254 | + | <div> | |
| 255 | + | <dt className="flex items-center gap-1.5 text-xs text-faint"> | |
| 256 | + | <Wallet size={12} /> Agents' spend for them this month | |
| 257 | + | </dt> | |
| 258 | + | <dd className="mt-0.5 tabular-nums"> | |
| 259 | + | {money(spend.spent)} | |
| 260 | + | {spend.budget != null && <span className="text-muted"> of {money(spend.budget)}</span>} | |
| 261 | + | </dd> | |
| 262 | + | </div> | |
| 263 | + | )} | |
| 264 | + | </dl> | |
| 265 | + | </Card> | |
| 266 | + | </div> | |
| 267 | + | </div> | |
| 268 | + | ); | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | function Card({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) { | |
| 272 | + | return ( | |
| 273 | + | <section className="rounded-xl border border-line bg-surface p-4"> | |
| 274 | + | <h2 className="mb-3 flex items-center gap-2 text-sm font-medium text-muted"> | |
| 275 | + | <span className="text-faint">{icon}</span> | |
| 276 | + | {title} | |
| 277 | + | </h2> | |
| 278 | + | {children} | |
| 279 | + | </section> | |
| 280 | + | ); | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | function Quiet({ children }: { children: ReactNode }) { | |
| 284 | + | return <p className="text-sm text-faint">{children}</p>; | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | function PersonRow({ slug, person }: { slug: string; person: Pick<DirectoryPerson, "user_id" | "username" | "display_username" | "name" | "avatar" | "title"> }) { | |
| 288 | + | return ( | |
| 289 | + | <span className="flex min-w-0 items-center gap-2.5"> | |
| 290 | + | <PersonFace person={person} size={24} ring="var(--color-surface)" /> | |
| 291 | + | <span className="min-w-0 truncate"> | |
| 292 | + | <PersonLink workspace={slug} person={person} className="font-medium" /> | |
| 293 | + | {person.title && <span className="text-muted"> · {person.title}</span>} | |
| 294 | + | </span> | |
| 295 | + | </span> | |
| 296 | + | ); | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | /** Their title and what they own (theirs, or an owner's to change), and who they report to (an owner's). */ | |
| 300 | + | function EditProfile({ | |
| 301 | + | person, | |
| 302 | + | people, | |
| 303 | + | canManage, | |
| 304 | + | actionData, | |
| 305 | + | }: { | |
| 306 | + | person: DirectoryPerson; | |
| 307 | + | people: DirectoryPerson[]; | |
| 308 | + | canManage: boolean; | |
| 309 | + | actionData: { intent: string; error: string | null; saved?: true } | undefined; | |
| 310 | + | }) { | |
| 311 | + | const [open, setOpen] = useState(false); | |
| 312 | + | const saved = actionData?.intent === "profile" && actionData.saved; | |
| 313 | + | useEffect(() => { | |
| 314 | + | if (saved) setOpen(false); | |
| 315 | + | }, [saved, actionData]); | |
| 316 | + | const choices = managerChoices(people, person.username); | |
| 317 | + | return ( | |
| 318 | + | <> | |
| 319 | + | <Button variant="quiet" onClick={() => setOpen(true)}> | |
| 320 | + | <Pencil size={14} /> | |
| 321 | + | Edit profile | |
| 322 | + | </Button> | |
| 323 | + | <Dialog open={open} onOpenChange={setOpen}> | |
| 324 | + | <DialogContent> | |
| 325 | + | <DialogHeader> | |
| 326 | + | <DialogTitle>Edit {personName(person)}'s profile</DialogTitle> | |
| 327 | + | <DialogDescription>Shown in the directory, and told to the agents on their teams.</DialogDescription> | |
| 328 | + | </DialogHeader> | |
| 329 | + | <Form method="post" className="space-y-4"> | |
| 330 | + | <input type="hidden" name="intent" value="profile" /> | |
| 331 | + | <Field label="Title" hint="Their job here, such as Staff Engineer."> | |
| 332 | + | <Input name="title" defaultValue={person.title ?? ""} maxLength={80} placeholder="Staff Engineer" /> | |
| 333 | + | </Field> | |
| 334 | + | <Field label="What they own" hint="One per line: a product, a process, an account. Agents ask them about these."> | |
| 335 | + | <Textarea name="owns" defaultValue={person.owns.join("\n")} rows={3} placeholder={"storefront\nthe release process"} /> | |
| 336 | + | </Field> | |
| 337 | + | {canManage && ( | |
| 338 | + | <label className="block"> | |
| 339 | + | <span className="mb-1.5 block text-sm font-medium text-muted">Reports to</span> | |
| 340 | + | <SelectField | |
| 341 | + | name="manager" | |
| 342 | + | defaultValue={person.manager ?? "none"} | |
| 343 | + | className="w-full" | |
| 344 | + | options={[{ value: "none", label: "No one" }, ...choices.map((p) => ({ value: p.username, label: `${personName(p)}${p.title ? ` · ${p.title}` : ""}` }))]} | |
| 345 | + | /> | |
| 346 | + | </label> | |
| 347 | + | )} | |
| 348 | + | {actionData?.intent === "profile" && <ErrorText>{actionData.error}</ErrorText>} | |
| 349 | + | <DialogFooter> | |
| 350 | + | <Button variant="quiet" type="button" onClick={() => setOpen(false)}> | |
| 351 | + | Cancel | |
| 352 | + | </Button> | |
| 353 | + | <SubmitButton pending="Saving…" match={{ intent: "profile" }}> | |
| 354 | + | Save | |
| 355 | + | </SubmitButton> | |
| 356 | + | </DialogFooter> | |
| 357 | + | </Form> | |
| 358 | + | </DialogContent> | |
| 359 | + | </Dialog> | |
| 360 | + | </> | |
| 361 | + | ); | |
| 362 | + | } |
| 1 | − | import { Box, ChevronRight, Network, Settings, Users } from "lucide-react"; | |
| 1 | + | import { Box, ChevronRight, Code2, Crown, Database, Hash, Network, Settings, Users, Wallet } from "lucide-react"; | |
| 2 | + | import type { ReactNode } from "react"; | |
| 2 | 3 | import { Link, Outlet, data } from "react-router"; | |
| 3 | 4 | ||
| 4 | − | import { teamHandle } from "@g1t/contracts"; | |
| 5 | + | import { teamHandle, teamKindLabel } from "@g1t/contracts"; | |
| 5 | 6 | ||
| 6 | 7 | import type { Route } from "./+types/layout"; | |
| 7 | 8 | import { page } from "../../../lib/meta"; | |
| 8 | 9 | import { TeamBadges, type TeamContext } from "../../../components/teams"; | |
| 10 | + | import { Coming, TeamKindMark } from "../../../components/people"; | |
| 9 | 11 | import { CopyLine, TabLink } from "../../../components/ui"; | |
| 12 | + | import { Hint } from "../../../components/ui/hint"; | |
| 10 | 13 | import { TabStrip } from "../../../components/ui/tab-strip"; | |
| 14 | + | import { agentsOn, agentPath, peopleAgent, personPath } from "../../../lib/people"; | |
| 11 | 15 | import { teamPath } from "../../../lib/teams"; | |
| 12 | − | import { identity } from "../../../lib/services.server"; | |
| 16 | + | import { money } from "../../../lib/usage"; | |
| 17 | + | import { identity, workspaceAgents } from "../../../lib/services.server"; | |
| 13 | 18 | import { getViewer, roleIn } from "../../../lib/session.server"; | |
| 14 | 19 | ||
| 15 | 20 | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { | |
| ⋯ | |||
| 19 | 24 | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 20 | 25 | const viewer = getViewer(context); | |
| 21 | 26 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 22 | − | const found = await identity.getTeam(viewer, params.owner, params.team); | |
| 27 | + | const [found, added, listed] = await Promise.all([ | |
| 28 | + | identity.getTeam(viewer, params.owner, params.team), | |
| 29 | + | identity.teamAgents(viewer, params.owner, params.team).catch(() => null), | |
| 30 | + | workspaceAgents.list(params.owner, viewer!).catch(() => null), | |
| 31 | + | ]); | |
| 23 | 32 | // A secret team someone is not in looks like no team at all. | |
| 24 | 33 | if (!found.ok) throw data(null, { status: 404 }); | |
| 25 | − | return { team: found.value }; | |
| 34 | + | const team = found.value; | |
| 35 | + | const ids = added?.ok ? added.value.map((agent) => agent.agent_id) : []; | |
| 36 | + | const all = listed?.ok ? listed.value.map(peopleAgent) : []; | |
| 37 | + | return { team, agents: agentsOn({ slug: team.slug, agent_ids: ids }, all), added: ids }; | |
| 26 | 38 | } | |
| 27 | 39 | ||
| 28 | 40 | export default function TeamLayout({ loaderData }: Route.ComponentProps) { | |
| 29 | − | const { team } = loaderData; | |
| 41 | + | const { team, agents, added } = loaderData; | |
| 30 | 42 | const base = teamPath(team.workspace, team.slug); | |
| 43 | + | const lead = | |
| 44 | + | team.lead?.kind === "user" | |
| 45 | + | ? { name: team.lead.name ?? team.lead.username, to: personPath(team.workspace, team.lead.username) } | |
| 46 | + | : team.lead?.kind === "agent" | |
| 47 | + | ? (() => { | |
| 48 | + | const agentId = team.lead.agent_id; | |
| 49 | + | const agent = agents.find((a) => a.id === agentId); | |
| 50 | + | return agent ? { name: agent.display_name, to: agentPath(team.workspace, agent.handle) } : null; | |
| 51 | + | })() | |
| 52 | + | : null; | |
| 53 | + | const spent = agents.reduce((sum, agent) => sum + agent.spent_month_micros, 0); | |
| 31 | 54 | return ( | |
| 32 | 55 | <div> | |
| 33 | 56 | <nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-1 text-sm text-muted"> | |
| ⋯ | |||
| 51 | 74 | <TeamBadges team={team} /> | |
| 52 | 75 | </div> | |
| 53 | 76 | {team.description && <p className="mt-1.5 text-sm text-muted">{team.description}</p>} | |
| 77 | + | <ul aria-label="What the team has" className="mt-3 flex flex-wrap gap-1.5"> | |
| 78 | + | <Fact> | |
| 79 | + | <TeamKindMark people={team.members_count} agents={agents.length} /> | |
| 80 | + | {teamKindLabel(team.members_count, agents.length)} | |
| 81 | + | </Fact> | |
| 82 | + | <Fact icon={<Crown size={12} />}> | |
| 83 | + | {lead ? ( | |
| 84 | + | <> | |
| 85 | + | Led by{" "} | |
| 86 | + | <Link to={lead.to} className="font-medium text-fg hover:text-accent"> | |
| 87 | + | {lead.name} | |
| 88 | + | </Link> | |
| 89 | + | </> | |
| 90 | + | ) : ( | |
| 91 | + | "No lead" | |
| 92 | + | )} | |
| 93 | + | </Fact> | |
| 94 | + | <Fact icon={<Hash size={12} />}> | |
| 95 | + | {team.channel ? ( | |
| 96 | + | <Link to={`/${team.workspace}/-/chat/${team.channel.name}`} className="font-medium text-fg hover:text-accent"> | |
| 97 | + | {team.channel.name} | |
| 98 | + | </Link> | |
| 99 | + | ) : ( | |
| 100 | + | "No channel" | |
| 101 | + | )} | |
| 102 | + | </Fact> | |
| 103 | + | <Fact icon={<Code2 size={12} />}> | |
| 104 | + | <Hint label="Its roles on repositories, given to everyone on it and its child teams. Agents on it work with the access of whoever asks them."> | |
| 105 | + | <Link to={`${base}/repositories`} className="hover:text-fg"> | |
| 106 | + | Code: {team.repos_count ? `${team.repos_count} ${team.repos_count === 1 ? "repository" : "repositories"}` : "no roles yet"} | |
| 107 | + | </Link> | |
| 108 | + | </Hint> | |
| 109 | + | </Fact> | |
| 110 | + | <Fact icon={<Database size={12} />}> | |
| 111 | + | Storage <Coming /> | |
| 112 | + | </Fact> | |
| 113 | + | <Fact icon={<Wallet size={12} />}> | |
| 114 | + | {team.budget_micros ? ( | |
| 115 | + | <span className="tabular-nums"> | |
| 116 | + | {money(spent)} of {money(team.budget_micros)} this month | |
| 117 | + | </span> | |
| 118 | + | ) : ( | |
| 119 | + | "No team budget" | |
| 120 | + | )} | |
| 121 | + | </Fact> | |
| 122 | + | </ul> | |
| 54 | 123 | <div className="mt-3 max-w-xs"> | |
| 55 | 124 | <CopyLine text={teamHandle(team)} /> | |
| 56 | 125 | </div> | |
| 57 | 126 | <TabStrip label="Team" className="mt-5 gap-1"> | |
| 58 | − | <TabLink to={base} end icon={<Users size={15} />} count={team.members_count}> | |
| 59 | − | Members | |
| 127 | + | <TabLink to={base} end icon={<Users size={15} />} count={team.members_count + agents.length}> | |
| 128 | + | People and agents | |
| 60 | 129 | </TabLink> | |
| 61 | 130 | <TabLink to={`${base}/teams`} icon={<Network size={15} />} count={team.child_teams_count}> | |
| 62 | 131 | Child teams | |
| ⋯ | |||
| 72 | 141 | </TabStrip> | |
| 73 | 142 | </header> | |
| 74 | 143 | <div className="pt-6"> | |
| 75 | − | <Outlet context={{ team } satisfies TeamContext} /> | |
| 144 | + | <Outlet context={{ team, agents, added } satisfies TeamContext} /> | |
| 76 | 145 | </div> | |
| 77 | 146 | </div> | |
| 78 | 147 | ); | |
| 79 | 148 | } | |
| 149 | + | ||
| 150 | + | function Fact({ icon, children }: { icon?: ReactNode; children: ReactNode }) { | |
| 151 | + | return ( | |
| 152 | + | <li className="inline-flex items-center gap-1.5 rounded-full border border-line bg-surface px-2.5 py-0.5 text-xs text-muted"> | |
| 153 | + | {icon && <span className="text-faint">{icon}</span>} | |
| 154 | + | {children} | |
| 155 | + | </li> | |
| 156 | + | ); | |
| 157 | + | } | |
| 1 | − | import { Users } from "lucide-react"; | |
| 1 | + | import { Bot, Sparkles, Users } from "lucide-react"; | |
| 2 | 2 | import { Form, Link, data, useSearchParams } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { TeamRole } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/members"; | |
| 7 | − | import { useTeam } from "../../../components/teams"; | |
| 7 | + | import { AgentLink, AgentPersonFace, ToldText } from "../../../components/people"; | |
| 8 | + | import { useTeam, useTeamAgents } from "../../../components/teams"; | |
| 8 | 9 | import { Avatar, ErrorText, Field, Input, SubmitButton } from "../../../components/ui"; | |
| 9 | 10 | import { Badge } from "../../../components/ui/badge"; | |
| 10 | 11 | import { CheckboxOption } from "../../../components/ui/checkbox"; | |
| 12 | + | import { SelectField } from "../../../components/ui/select"; | |
| 11 | 13 | import { Switch } from "../../../components/ui/switch"; | |
| 14 | + | import { WithPresence } from "../../../components/presence"; | |
| 15 | + | import { agentsOn, leads, peopleAgent, personPath, teamBlock } from "../../../lib/people"; | |
| 12 | 16 | import { teamPath } from "../../../lib/teams"; | |
| 13 | − | import { identity } from "../../../lib/services.server"; | |
| 17 | + | import { identity, workspaceAgents } from "../../../lib/services.server"; | |
| 14 | 18 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server"; | |
| 15 | 19 | import { UserCard } from "../../../components/user-card"; | |
| 16 | 20 | ||
| ⋯ | |||
| 18 | 22 | const viewer = getViewer(context); | |
| 19 | 23 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 20 | 24 | const children = new URL(request.url).searchParams.get("children") === "1"; | |
| 21 | − | const members = unwrap(await identity.teamMembers(viewer, params.owner, params.team, children)); | |
| 22 | − | return { members, children, me: viewer?.username ?? null }; | |
| 25 | + | const [members, team, added, listed] = await Promise.all([ | |
| 26 | + | identity.teamMembers(viewer, params.owner, params.team, children).then(unwrap), | |
| 27 | + | identity.getTeam(viewer, params.owner, params.team).then(unwrap), | |
| 28 | + | identity.teamAgents(viewer, params.owner, params.team).catch(() => null), | |
| 29 | + | workspaceAgents.list(params.owner, viewer!).catch(() => null), | |
| 30 | + | ]); | |
| 31 | + | const all = listed?.ok ? listed.value.map(peopleAgent) : []; | |
| 32 | + | const ids = added?.ok ? added.value.map((agent) => agent.agent_id) : []; | |
| 33 | + | const onTeam = agentsOn({ slug: team.slug, agent_ids: ids }, all); | |
| 34 | + | // What the team's first agent is told about it, word for word: every agent on it is told the same about this team. | |
| 35 | + | const first = onTeam.find((agent) => !agent.builtin); | |
| 36 | + | const told = first ? await workspaceAgents.teamContext(params.owner, first.handle, viewer!).catch(() => null) : null; | |
| 37 | + | return { | |
| 38 | + | members, | |
| 39 | + | children, | |
| 40 | + | me: viewer?.username ?? null, | |
| 41 | + | // The workspace's agents not on the team yet, for adding one. | |
| 42 | + | addable: all.filter((agent) => !agent.builtin && !onTeam.some((on) => on.id === agent.id)).map(({ id, display_name, title, role }) => ({ id, display_name, title, role })), | |
| 43 | + | told: first && told?.ok ? { agent: first, text: teamBlock(told.value.text, team.name) } : null, | |
| 44 | + | }; | |
| 23 | 45 | } | |
| 24 | 46 | ||
| 25 | 47 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| ⋯ | |||
| 27 | 49 | const user = requireUser(context, request); | |
| 28 | 50 | const form = await request.formData(); | |
| 29 | 51 | const intent = String(form.get("intent") ?? ""); | |
| 52 | + | if (intent === "add-agent" || intent === "remove-agent") { | |
| 53 | + | const agentId = String(form.get("agent_id") ?? ""); | |
| 54 | + | // Identity knows agents only by id: check it is one of this workspace's. | |
| 55 | + | const listed = await workspaceAgents.list(params.owner, user).catch(() => null); | |
| 56 | + | const agent = listed?.ok ? listed.value.find((a) => a.id === agentId) : null; | |
| 57 | + | if (!agent) return { intent, username: agentId, error: "Choose one of the workspace's agents." }; | |
| 58 | + | const done = | |
| 59 | + | intent === "add-agent" | |
| 60 | + | ? await identity.setTeamAgent(user, params.owner, params.team, agent.id) | |
| 61 | + | : await identity.removeTeamAgent(user, params.owner, params.team, agent.id); | |
| 62 | + | return done.ok ? { intent, username: agentId, error: null } : { intent, username: agentId, error: done.error.message }; | |
| 63 | + | } | |
| 30 | 64 | const username = String(form.get("username") ?? "").trim().replace(/^@/, ""); | |
| 31 | 65 | if (!username) return { intent, username, error: "Enter a username." }; | |
| 32 | 66 | if (intent === "remove") { | |
| ⋯ | |||
| 40 | 74 | ||
| 41 | 75 | export default function TeamMembers({ loaderData, actionData }: Route.ComponentProps) { | |
| 42 | 76 | const team = useTeam(); | |
| 43 | − | const { members, children, me } = loaderData; | |
| 77 | + | const { agents, added } = useTeamAgents(); | |
| 78 | + | const { members, children, me, addable, told } = loaderData; | |
| 44 | 79 | const [, setParams] = useSearchParams(); | |
| 45 | 80 | const manage = team.can_manage; | |
| 46 | 81 | const rowError = (username: string) => | |
| 47 | − | actionData && actionData.intent !== "add" && actionData.username === username ? actionData.error : null; | |
| 82 | + | actionData && actionData.intent !== "add" && actionData.intent !== "add-agent" && actionData.username === username ? actionData.error : null; | |
| 48 | 83 | ||
| 49 | 84 | return ( | |
| 50 | − | <div className="max-w-3xl space-y-6"> | |
| 51 | − | {team.child_teams_count > 0 && ( | |
| 52 | − | <label className="flex items-center gap-2.5 text-sm text-muted"> | |
| 53 | − | <Switch | |
| 54 | − | size="sm" | |
| 55 | − | checked={children} | |
| 56 | − | onCheckedChange={(on) => | |
| 57 | − | setParams(on ? { children: "1" } : {}, { replace: true, preventScrollReset: true }) | |
| 58 | − | } | |
| 59 | − | /> | |
| 60 | − | Include the people of child teams | |
| 61 | − | </label> | |
| 62 | − | )} | |
| 85 | + | <div className="max-w-3xl space-y-10"> | |
| 86 | + | <section aria-labelledby="team-people" className="space-y-4"> | |
| 87 | + | <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 88 | + | <h2 id="team-people" className="flex items-center gap-2 font-medium"> | |
| 89 | + | <Users size={15} className="text-faint" /> | |
| 90 | + | People <span className="text-sm font-normal text-faint tabular-nums">{team.members_count}</span> | |
| 91 | + | </h2> | |
| 92 | + | {team.child_teams_count > 0 && ( | |
| 93 | + | <label className="flex items-center gap-2.5 text-sm text-muted"> | |
| 94 | + | <Switch | |
| 95 | + | size="sm" | |
| 96 | + | checked={children} | |
| 97 | + | onCheckedChange={(on) => setParams(on ? { children: "1" } : {}, { replace: true, preventScrollReset: true })} | |
| 98 | + | /> | |
| 99 | + | Include the people of child teams | |
| 100 | + | </label> | |
| 101 | + | )} | |
| 102 | + | </div> | |
| 63 | 103 | ||
| 64 | − | {members.length === 0 ? ( | |
| 65 | − | <div className="rounded-xl border border-dashed border-line px-6 py-10 text-center"> | |
| 66 | − | <Users size={18} className="mx-auto text-faint" /> | |
| 67 | − | <p className="mt-2 text-sm font-medium">No one is in {team.name} yet</p> | |
| 68 | − | {manage && <p className="mt-1 text-sm text-muted">Add members of {team.workspace} below.</p>} | |
| 69 | − | </div> | |
| 70 | − | ) : ( | |
| 71 | − | <ul className="divide-y divide-line rounded-xl border border-line"> | |
| 72 | − | {members.map((member) => { | |
| 73 | − | const self = member.username === me; | |
| 74 | − | return ( | |
| 75 | − | <li key={member.username} className="px-4 py-3"> | |
| 76 | − | <div className="flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 77 | − | <Avatar name={member.username} image={member.avatar} size={28} /> | |
| 78 | − | <div className="min-w-0 grow basis-32 truncate"> | |
| 79 | − | <UserCard username={member.username}> | |
| 80 | − | <Link to={`/u/${member.username}`} className="font-mono text-sm hover:text-accent"> | |
| 81 | − | {member.username} | |
| 104 | + | {members.length === 0 ? ( | |
| 105 | + | <p className="rounded-xl border border-dashed border-line px-6 py-6 text-center text-sm text-muted"> | |
| 106 | + | {agents.length ? `No people on ${team.name}: it's agents only.` : `No one is in ${team.name} yet.`} | |
| 107 | + | {manage && " Add members of the workspace below."} | |
| 108 | + | </p> | |
| 109 | + | ) : ( | |
| 110 | + | <ul className="divide-y divide-line rounded-xl border border-line"> | |
| 111 | + | {members.map((member) => { | |
| 112 | + | const self = member.username === me; | |
| 113 | + | return ( | |
| 114 | + | <li key={member.username} className="px-4 py-3"> | |
| 115 | + | <div className="flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 116 | + | <WithPresence person={{ username: member.username }} size={28}> | |
| 117 | + | <Avatar name={member.username} image={member.avatar} size={28} /> | |
| 118 | + | </WithPresence> | |
| 119 | + | <div className="min-w-0 grow basis-32 truncate"> | |
| 120 | + | <UserCard username={member.username}> | |
| 121 | + | <Link to={personPath(team.workspace, member.username)} className="text-sm font-medium hover:text-accent"> | |
| 122 | + | {member.name || member.username} | |
| 123 | + | </Link> | |
| 124 | + | </UserCard> | |
| 125 | + | {member.name && <span className="ml-2 hidden font-mono text-xs text-faint sm:inline">{member.username}</span>} | |
| 126 | + | </div> | |
| 127 | + | {leads(team.lead, { username: member.username }) && <Badge tone="accent">Lead</Badge>} | |
| 128 | + | {member.via ? ( | |
| 129 | + | <Link to={teamPath(team.workspace, member.via)}> | |
| 130 | + | <Badge>via {member.via}</Badge> | |
| 82 | 131 | </Link> | |
| 83 | − | </UserCard> | |
| 84 | − | {member.name && <span className="ml-2 hidden text-sm text-muted sm:inline">{member.name}</span>} | |
| 85 | − | </div> | |
| 86 | − | {member.via ? ( | |
| 87 | − | <Link to={teamPath(team.workspace, member.via)}> | |
| 88 | − | <Badge>via {member.via}</Badge> | |
| 89 | − | </Link> | |
| 90 | − | ) : member.role === "maintainer" ? ( | |
| 91 | − | <Badge tone="accent">Maintainer</Badge> | |
| 92 | − | ) : ( | |
| 93 | − | <Badge>Member</Badge> | |
| 94 | − | )} | |
| 95 | − | {!member.via && (manage || self) && ( | |
| 96 | − | <span className="flex items-center gap-1"> | |
| 97 | − | {manage && ( | |
| 132 | + | ) : member.role === "maintainer" ? ( | |
| 133 | + | <Badge tone="accent">Maintainer</Badge> | |
| 134 | + | ) : ( | |
| 135 | + | <Badge>Member</Badge> | |
| 136 | + | )} | |
| 137 | + | {!member.via && (manage || self) && ( | |
| 138 | + | <span className="flex items-center gap-1"> | |
| 139 | + | {manage && ( | |
| 140 | + | <Form method="post"> | |
| 141 | + | <input type="hidden" name="intent" value="role" /> | |
| 142 | + | <input type="hidden" name="username" value={member.username} /> | |
| 143 | + | <input type="hidden" name="role" value={member.role === "maintainer" ? "member" : "maintainer"} /> | |
| 144 | + | <SubmitButton variant="quiet" match={{ intent: "role", username: member.username }} pending="Saving…"> | |
| 145 | + | {member.role === "maintainer" ? "Make member" : "Make maintainer"} | |
| 146 | + | </SubmitButton> | |
| 147 | + | </Form> | |
| 148 | + | )} | |
| 98 | 149 | <Form method="post"> | |
| 99 | − | <input type="hidden" name="intent" value="role" /> | |
| 150 | + | <input type="hidden" name="intent" value="remove" /> | |
| 100 | 151 | <input type="hidden" name="username" value={member.username} /> | |
| 101 | − | <input type="hidden" name="role" value={member.role === "maintainer" ? "member" : "maintainer"} /> | |
| 102 | − | <SubmitButton variant="quiet" match={{ intent: "role", username: member.username }} pending="Saving…"> | |
| 103 | − | {member.role === "maintainer" ? "Make member" : "Make maintainer"} | |
| 152 | + | <SubmitButton variant="quiet" match={{ intent: "remove", username: member.username }} pending="Removing…"> | |
| 153 | + | {self ? "Leave" : "Remove"} | |
| 104 | 154 | </SubmitButton> | |
| 105 | 155 | </Form> | |
| 106 | − | )} | |
| 107 | − | <Form method="post"> | |
| 108 | − | <input type="hidden" name="intent" value="remove" /> | |
| 109 | − | <input type="hidden" name="username" value={member.username} /> | |
| 110 | − | <SubmitButton variant="quiet" match={{ intent: "remove", username: member.username }} pending="Removing…"> | |
| 111 | − | {self ? "Leave" : "Remove"} | |
| 112 | − | </SubmitButton> | |
| 113 | − | </Form> | |
| 114 | − | </span> | |
| 115 | − | )} | |
| 116 | − | </div> | |
| 117 | − | <ErrorText>{rowError(member.username)}</ErrorText> | |
| 118 | − | </li> | |
| 119 | − | ); | |
| 120 | − | })} | |
| 121 | − | </ul> | |
| 122 | − | )} | |
| 156 | + | </span> | |
| 157 | + | )} | |
| 158 | + | </div> | |
| 159 | + | <ErrorText>{rowError(member.username)}</ErrorText> | |
| 160 | + | </li> | |
| 161 | + | ); | |
| 162 | + | })} | |
| 163 | + | </ul> | |
| 164 | + | )} | |
| 123 | 165 | ||
| 124 | − | {manage && ( | |
| 125 | − | <Form method="post" key={members.length} className="flex flex-col gap-3 sm:flex-row sm:items-start"> | |
| 126 | − | <input type="hidden" name="intent" value="add" /> | |
| 127 | − | <div className="grow"> | |
| 128 | − | <Field label="Add a member" hint={`A member of ${team.workspace}, by username. Maintainers manage the team's people and settings.`}> | |
| 129 | − | <Input name="username" required maxLength={39} placeholder="username" /> | |
| 130 | − | </Field> | |
| 131 | − | </div> | |
| 132 | − | <CheckboxOption name="role" value="maintainer" label="Maintainer" className="sm:pt-[2.1rem]" /> | |
| 166 | + | {manage && ( | |
| 167 | + | <Form method="post" key={members.length} className="flex flex-col gap-3 sm:flex-row sm:items-start"> | |
| 168 | + | <input type="hidden" name="intent" value="add" /> | |
| 169 | + | <div className="grow"> | |
| 170 | + | <Field label="Add a person" hint={`A member of ${team.workspace}, by username. Maintainers manage the team's people, agents and settings.`}> | |
| 171 | + | <Input name="username" required maxLength={39} placeholder="username" /> | |
| 172 | + | </Field> | |
| 173 | + | </div> | |
| 174 | + | <CheckboxOption name="role" value="maintainer" label="Maintainer" className="sm:pt-[2.1rem]" /> | |
| 175 | + | <div className="sm:pt-[1.625rem]"> | |
| 176 | + | <SubmitButton match={{ intent: "add" }} pending="Adding…"> | |
| 177 | + | Add | |
| 178 | + | </SubmitButton> | |
| 179 | + | </div> | |
| 180 | + | </Form> | |
| 181 | + | )} | |
| 182 | + | {actionData?.intent === "add" && <ErrorText>{actionData.error}</ErrorText>} | |
| 183 | + | </section> | |
| 133 | 184 | ||
| 134 | − | <div className="sm:pt-[1.625rem]"> | |
| 135 | − | <SubmitButton match={{ intent: "add" }} pending="Adding…"> | |
| 185 | + | <section aria-labelledby="team-agents" className="space-y-4"> | |
| 186 | + | <h2 id="team-agents" className="flex items-center gap-2 font-medium"> | |
| 187 | + | <Bot size={15} className="text-faint" /> | |
| 188 | + | Agents <span className="text-sm font-normal text-faint tabular-nums">{agents.length}</span> | |
| 189 | + | </h2> | |
| 190 | + | {agents.length === 0 ? ( | |
| 191 | + | <p className="rounded-xl border border-dashed border-line px-6 py-6 text-center text-sm text-muted"> | |
| 192 | + | No agents on {team.name}: it's people only.{manage && addable.length > 0 && " Add one below."} | |
| 193 | + | </p> | |
| 194 | + | ) : ( | |
| 195 | + | <ul className="divide-y divide-line rounded-xl border border-line"> | |
| 196 | + | {agents.map((agent) => { | |
| 197 | + | const home = !added.includes(agent.id); | |
| 198 | + | return ( | |
| 199 | + | <li key={agent.id} className="px-4 py-3"> | |
| 200 | + | <div className="flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 201 | + | <AgentPersonFace agent={agent} size={28} /> | |
| 202 | + | <div className="min-w-0 grow basis-32 truncate text-sm"> | |
| 203 | + | <AgentLink workspace={team.workspace} agent={agent} className="font-medium" /> | |
| 204 | + | <span className="ml-2 text-muted">{agent.title || agent.role}</span> | |
| 205 | + | </div> | |
| 206 | + | {leads(team.lead, { id: agent.id }) && <Badge tone="accent">Lead</Badge>} | |
| 207 | + | {home ? ( | |
| 208 | + | <Badge>Home team</Badge> | |
| 209 | + | ) : ( | |
| 210 | + | manage && ( | |
| 211 | + | <Form method="post"> | |
| 212 | + | <input type="hidden" name="intent" value="remove-agent" /> | |
| 213 | + | <input type="hidden" name="agent_id" value={agent.id} /> | |
| 214 | + | <SubmitButton variant="quiet" match={{ intent: "remove-agent", agent_id: agent.id }} pending="Removing…"> | |
| 215 | + | Remove | |
| 216 | + | </SubmitButton> | |
| 217 | + | </Form> | |
| 218 | + | ) | |
| 219 | + | )} | |
| 220 | + | </div> | |
| 221 | + | <ErrorText>{rowError(agent.id)}</ErrorText> | |
| 222 | + | </li> | |
| 223 | + | ); | |
| 224 | + | })} | |
| 225 | + | </ul> | |
| 226 | + | )} | |
| 227 | + | {manage && addable.length > 0 && ( | |
| 228 | + | <Form method="post" key={`agents:${agents.length}`} className="flex flex-col gap-3 sm:flex-row sm:items-end"> | |
| 229 | + | <input type="hidden" name="intent" value="add-agent" /> | |
| 230 | + | <label className="block grow"> | |
| 231 | + | <span className="mb-1.5 block text-sm font-medium text-muted">Add an agent</span> | |
| 232 | + | <SelectField | |
| 233 | + | name="agent_id" | |
| 234 | + | defaultValue={addable[0]!.id} | |
| 235 | + | className="w-full" | |
| 236 | + | options={addable.map((agent) => ({ value: agent.id, label: `${agent.display_name} · ${agent.title || agent.role}` }))} | |
| 237 | + | /> | |
| 238 | + | </label> | |
| 239 | + | <SubmitButton match={{ intent: "add-agent" }} pending="Adding…"> | |
| 136 | 240 | Add | |
| 137 | 241 | </SubmitButton> | |
| 242 | + | </Form> | |
| 243 | + | )} | |
| 244 | + | {actionData?.intent === "add-agent" && <ErrorText>{actionData.error}</ErrorText>} | |
| 245 | + | </section> | |
| 246 | + | ||
| 247 | + | {told && ( | |
| 248 | + | <section aria-labelledby="team-told" className="rounded-xl border border-accent/40 bg-surface p-4"> | |
| 249 | + | <div className="mb-3 flex flex-wrap items-baseline justify-between gap-2"> | |
| 250 | + | <h2 id="team-told" className="flex items-center gap-2 text-sm font-medium"> | |
| 251 | + | <Sparkles size={14} className="text-accent" /> | |
| 252 | + | What the agents on {team.name} know about it | |
| 253 | + | </h2> | |
| 254 | + | <span className="text-xs text-faint">As {told.agent.display_name} is told it every turn, from this page and who's around now</span> | |
| 138 | 255 | </div> | |
| 139 | − | </Form> | |
| 256 | + | {told.text ? ( | |
| 257 | + | <ToldText text={told.text} /> | |
| 258 | + | ) : ( | |
| 259 | + | <p className="text-sm text-faint">{team.visibility === "secret" ? "Agents aren't told about secret teams." : "It couldn't be read just now."}</p> | |
| 260 | + | )} | |
| 261 | + | </section> | |
| 140 | 262 | )} | |
| 141 | − | {actionData?.intent === "add" && <ErrorText>{actionData.error}</ErrorText>} | |
| 142 | 263 | </div> | |
| 143 | 264 | ); | |
| 144 | 265 | } | |
| 12 | 12 | ||
| 13 | 13 | import type { Route } from "./+types/settings"; | |
| 14 | 14 | import { ConfirmDialog } from "../../../components/repo-lifecycle"; | |
| 15 | − | import { useTeam } from "../../../components/teams"; | |
| 15 | + | import { useTeam, useTeamAgents } from "../../../components/teams"; | |
| 16 | + | import { Coming } from "../../../components/people"; | |
| 17 | + | import { SelectField } from "../../../components/ui/select"; | |
| 18 | + | import { budgetFromText } from "../../../lib/people"; | |
| 16 | 19 | import { Button, ErrorText, Field, Input, SubmitButton } from "../../../components/ui"; | |
| 17 | 20 | import { Input as NumberInput } from "../../../components/ui/input"; | |
| 18 | 21 | import { Textarea } from "../../../components/ui/textarea"; | |
| ⋯ | |||
| 21 | 24 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select"; | |
| 22 | 25 | import { SwitchCard } from "../../../components/ui/switch"; | |
| 23 | 26 | import { parentChoices, reviewAssignmentFromForm, teamChangesFromForm, teamPath } from "../../../lib/teams"; | |
| 24 | − | import { identity } from "../../../lib/services.server"; | |
| 27 | + | import { chat, identity, workspaceAgents } from "../../../lib/services.server"; | |
| 25 | 28 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server"; | |
| 26 | 29 | ||
| 27 | 30 | /** The parent select's value for "no parent". */ | |
| 28 | 31 | const NO_PARENT = "-"; | |
| 32 | + | /** The lead select's value for no lead. */ | |
| 33 | + | const NO_LEAD = "none"; | |
| 34 | + | ||
| 35 | + | /** | |
| 36 | + | * Its lead, channel and budget. An agent made lead that is on the team | |
| 37 | + | * only through its home team is added to it first, as identity needs; a | |
| 38 | + | * channel is found by its name in Chat, as the viewer sees it. | |
| 39 | + | */ | |
| 40 | + | async function saveLeadChannelBudget(form: FormData, workspace: string, slug: string, user: Parameters<typeof identity.updateTeam>[0]) { | |
| 41 | + | const intent = "people"; | |
| 42 | + | const lead = String(form.get("lead") ?? NO_LEAD); | |
| 43 | + | const channel = String(form.get("channel") ?? "").trim().replace(/^#/, "").toLowerCase(); | |
| 44 | + | const budget = budgetFromText(String(form.get("budget") ?? "")); | |
| 45 | + | if (budget === null) return { intent, error: "Give the budget in dollars, such as 150.", saved: false }; | |
| 46 | + | if (lead.startsWith("agent:")) { | |
| 47 | + | const listed = await workspaceAgents.list(workspace, user).catch(() => null); | |
| 48 | + | const agent = listed?.ok ? listed.value.find((a) => `agent:${a.id}` === lead) : null; | |
| 49 | + | if (!agent) return { intent, error: "Choose one of the team's agents.", saved: false }; | |
| 50 | + | const added = await identity.setTeamAgent(user, workspace, slug, agent.id); | |
| 51 | + | if (!added.ok) return { intent, error: added.error.message, saved: false }; | |
| 52 | + | } | |
| 53 | + | let channelId = ""; | |
| 54 | + | let channelName = ""; | |
| 55 | + | if (channel) { | |
| 56 | + | const found = await chat.channelByName(workspace, channel, user).catch(() => null); | |
| 57 | + | if (!found?.ok || found.value.channel.kind !== "channel") return { intent, error: `There is no channel #${channel} you can see.`, saved: false }; | |
| 58 | + | channelId = found.value.channel.id; | |
| 59 | + | channelName = found.value.channel.name ?? channel; | |
| 60 | + | } | |
| 61 | + | const saved = await identity.updateTeam(user, workspace, slug, { | |
| 62 | + | lead: lead === NO_LEAD ? "" : lead, | |
| 63 | + | channel_id: channelId, | |
| 64 | + | channel_name: channelName, | |
| 65 | + | budget_micros: budget, | |
| 66 | + | }); | |
| 67 | + | return saved.ok ? { intent, error: null, saved: true } : { intent, error: saved.error.message, saved: false }; | |
| 68 | + | } | |
| 29 | 69 | ||
| 30 | 70 | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 31 | 71 | const viewer = getViewer(context); | |
| 32 | 72 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 33 | 73 | const team = unwrap(await identity.getTeam(viewer, params.owner, params.team)); | |
| 34 | 74 | if (!team.can_manage) throw data(null, { status: 404 }); | |
| 35 | − | const all = await identity.listTeams(viewer, params.owner).then((found) => (found.ok ? found.value : [])); | |
| 75 | + | const [all, members] = await Promise.all([ | |
| 76 | + | identity.listTeams(viewer, params.owner).then((found) => (found.ok ? found.value : [])), | |
| 77 | + | // Who can lead it: its own people (and its agents, from the layout). | |
| 78 | + | identity.teamMembers(viewer, params.owner, params.team, false).then((found) => (found.ok ? found.value : [])), | |
| 79 | + | ]); | |
| 36 | 80 | const parents = parentChoices(all, team.slug).filter((other) => other.can_manage || other.slug === team.parent?.slug); | |
| 37 | − | return { parents }; | |
| 81 | + | return { parents, people: members.map((member) => ({ username: member.username, name: member.name })) }; | |
| 38 | 82 | } | |
| 39 | 83 | ||
| 40 | 84 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| ⋯ | |||
| 53 | 97 | const saved = await identity.updateTeam(user, params.owner, params.team, { review_assignment: review }); | |
| 54 | 98 | return saved.ok ? { intent, error: null, saved: true } : { intent, error: saved.error.message, saved: false }; | |
| 55 | 99 | } | |
| 100 | + | if (intent === "people") return saveLeadChannelBudget(form, params.owner, params.team, user); | |
| 56 | 101 | const changes = teamChangesFromForm(form); | |
| 57 | 102 | if (changes.parent === NO_PARENT) changes.parent = ""; | |
| 58 | 103 | const saved = await identity.updateTeam(user, params.owner, params.team, changes); | |
| ⋯ | |||
| 64 | 109 | ||
| 65 | 110 | export default function TeamSettings({ loaderData, actionData }: Route.ComponentProps) { | |
| 66 | 111 | const team = useTeam(); | |
| 67 | − | const { parents } = loaderData; | |
| 112 | + | const { agents } = useTeamAgents(); | |
| 113 | + | const { parents, people } = loaderData; | |
| 114 | + | const leadValue = team.lead ? (team.lead.kind === "user" ? `@${team.lead.username}` : `agent:${team.lead.agent_id}`) : NO_LEAD; | |
| 68 | 115 | const said = (intent: string) => (actionData?.intent === intent ? actionData : null); | |
| 69 | 116 | const [visibility, setVisibility] = useState<TeamVisibility>(team.visibility); | |
| 70 | 117 | const review = team.review_assignment; | |
| ⋯ | |||
| 132 | 179 | </Form> | |
| 133 | 180 | </section> | |
| 134 | 181 | ||
| 182 | + | <section aria-labelledby="lead" className="border-t border-line pt-8"> | |
| 183 | + | <h2 id="lead" className="font-medium"> | |
| 184 | + | Lead, channel and budget | |
| 185 | + | </h2> | |
| 186 | + | <p className="mt-1 text-sm text-muted"> | |
| 187 | + | Everyone on {team.name}, agents included, is told who leads it and where it talks. Its budget caps what its agents spend | |
| 188 | + | together in a month; each agent keeps its own budget too. | |
| 189 | + | </p> | |
| 190 | + | <Form method="post" className="mt-4 space-y-5" key={`people-${team.updated_at}`}> | |
| 191 | + | <input type="hidden" name="intent" value="people" /> | |
| 192 | + | <label className="block"> | |
| 193 | + | <span className="mb-1.5 block text-sm font-medium text-muted">Lead</span> | |
| 194 | + | <SelectField | |
| 195 | + | name="lead" | |
| 196 | + | defaultValue={leadValue} | |
| 197 | + | className="w-full sm:max-w-72" | |
| 198 | + | options={[ | |
| 199 | + | { value: NO_LEAD, label: "No lead" }, | |
| 200 | + | ...people.map((person) => ({ value: `@${person.username}`, label: person.name ? `${person.name} (@${person.username})` : `@${person.username}` })), | |
| 201 | + | ...agents.map((agent) => ({ value: `agent:${agent.id}`, label: `${agent.display_name}, an agent` })), | |
| 202 | + | ]} | |
| 203 | + | /> | |
| 204 | + | <span className="mt-1.5 block text-xs text-faint">Someone on the team. Agents page the lead first when a person is needed.</span> | |
| 205 | + | </label> | |
| 206 | + | <Field label="Channel" hint="The team's channel in Chat, by name. Leave it empty for none."> | |
| 207 | + | <span className="block sm:max-w-72"> | |
| 208 | + | <Input name="channel" defaultValue={team.channel?.name ?? ""} placeholder="sales" /> | |
| 209 | + | </span> | |
| 210 | + | </Field> | |
| 211 | + | <Field label="Budget for its agents, a month" hint="In dollars. Empty or 0 for no team budget."> | |
| 212 | + | <span className="block sm:max-w-40"> | |
| 213 | + | <Input name="budget" inputMode="decimal" defaultValue={team.budget_micros ? String(team.budget_micros / 1_000_000) : ""} placeholder="150" /> | |
| 214 | + | </span> | |
| 215 | + | </Field> | |
| 216 | + | <div className="flex items-center gap-2 text-sm text-muted"> | |
| 217 | + | Storage level <Coming /> | |
| 218 | + | </div> | |
| 219 | + | <ErrorText>{said("people")?.error ?? null}</ErrorText> | |
| 220 | + | <div className="flex items-center gap-3"> | |
| 221 | + | <SubmitButton match={{ intent: "people" }} pending="Saving…"> | |
| 222 | + | Save | |
| 223 | + | </SubmitButton> | |
| 224 | + | {said("people")?.saved && <span className="text-sm text-muted">Saved.</span>} | |
| 225 | + | </div> | |
| 226 | + | </Form> | |
| 227 | + | </section> | |
| 228 | + | ||
| 135 | 229 | <section aria-labelledby="review" className="border-t border-line pt-8"> | |
| 136 | 230 | <h2 id="review" className="font-medium"> | |
| 137 | 231 | Code review assignment | |
| 8 | 8 | import { TeamRow } from "../../components/teams"; | |
| 9 | 9 | import { ButtonLink, EmptyState, notACredential } from "../../components/ui"; | |
| 10 | 10 | import { filterTeams, splitTeams } from "../../lib/teams"; | |
| 11 | − | import { identity } from "../../lib/services.server"; | |
| 11 | + | import { agentIdsOn } from "../../lib/people"; | |
| 12 | + | import { identity, workspaceAgents } from "../../lib/services.server"; | |
| 12 | 13 | import { getViewer, roleIn, unwrap } from "../../lib/session.server"; | |
| 13 | 14 | ||
| 14 | 15 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| ⋯ | |||
| 20 | 21 | // A workspace's teams are its members' business. | |
| 21 | 22 | const role = roleIn(viewer, params.owner); | |
| 22 | 23 | if (!role) throw data(null, { status: 404 }); | |
| 23 | − | const [teams, workspace] = await Promise.all([ | |
| 24 | + | const [teams, workspace, directory, agents] = await Promise.all([ | |
| 24 | 25 | identity.listTeams(viewer, params.owner).then(unwrap), | |
| 25 | 26 | identity.getWorkspace(params.owner).catch(() => null), | |
| 27 | + | // Which agents are on each team: added to it, or their home team. | |
| 28 | + | identity.peopleDirectory(viewer, params.owner).catch(() => null), | |
| 29 | + | workspaceAgents.list(params.owner, viewer!).catch(() => null), | |
| 26 | 30 | ]); | |
| 31 | + | const listed = agents?.ok ? agents.value : []; | |
| 32 | + | const agentCounts = Object.fromEntries( | |
| 33 | + | (directory?.ok ? directory.value.teams : []).map((team) => [team.slug, agentIdsOn(team, listed).length]), | |
| 34 | + | ); | |
| 27 | 35 | // Who may create one is the workspace's to say (its settings). | |
| 28 | − | return { teams, slug: params.owner.toLowerCase(), canCreate: mayCreateTeams(workspace?.teamCreation, role) }; | |
| 36 | + | return { teams, agentCounts, slug: params.owner.toLowerCase(), canCreate: mayCreateTeams(workspace?.teamCreation, role) }; | |
| 29 | 37 | } | |
| 30 | 38 | ||
| 31 | 39 | /** Searching changes only the address: the list is already here. */ | |
| ⋯ | |||
| 35 | 43 | } | |
| 36 | 44 | ||
| 37 | 45 | export default function WorkspaceTeams({ loaderData }: Route.ComponentProps) { | |
| 38 | − | const { teams, slug, canCreate } = loaderData; | |
| 46 | + | const { teams, agentCounts, slug, canCreate } = loaderData; | |
| 39 | 47 | const [params, setParams] = useSearchParams(); | |
| 40 | 48 | const query = params.get("q") ?? ""; | |
| 41 | 49 | const { mine, others } = splitTeams(filterTeams(teams, query)); | |
| ⋯ | |||
| 80 | 88 | ||
| 81 | 89 | {teams.length === 0 ? ( | |
| 82 | 90 | <EmptyState title="No teams yet"> | |
| 83 | − | A team gives a group of members a role on repositories at once. Mention it as{" "} | |
| 84 | − | <span className="font-mono text-fg">@{slug}/team</span>, or ask it to review a pull request. | |
| 91 | + | A team brings people and agents together: a lead, a channel, roles on repositories and a budget for its agents. | |
| 92 | + | Mention it as <span className="font-mono text-fg">@{slug}/team</span>, or ask it to review a pull request. | |
| 85 | 93 | </EmptyState> | |
| 86 | 94 | ) : mine.length + others.length === 0 ? ( | |
| 87 | 95 | <p className="rounded-xl border border-dashed border-line px-4 py-8 text-center text-sm text-muted"> | |
| ⋯ | |||
| 92 | 100 | </p> | |
| 93 | 101 | ) : ( | |
| 94 | 102 | <> | |
| 95 | − | {mine.length > 0 && <TeamList title="Your teams" teams={mine} />} | |
| 96 | − | {others.length > 0 && <TeamList title={mine.length > 0 ? "Other teams" : "All teams"} teams={others} />} | |
| 103 | + | {mine.length > 0 && <TeamList title="Your teams" teams={mine} agents={agentCounts} />} | |
| 104 | + | {others.length > 0 && <TeamList title={mine.length > 0 ? "Other teams" : "All teams"} teams={others} agents={agentCounts} />} | |
| 97 | 105 | </> | |
| 98 | 106 | )} | |
| 99 | 107 | </div> | |
| 100 | 108 | ); | |
| 101 | 109 | } | |
| 102 | 110 | ||
| 103 | − | function TeamList({ title, teams }: { title: string; teams: Team[] }) { | |
| 111 | + | function TeamList({ title, teams, agents }: { title: string; teams: Team[]; agents: Record<string, number> }) { | |
| 104 | 112 | return ( | |
| 105 | 113 | <section aria-label={title}> | |
| 106 | 114 | <h2 className="mb-3 text-sm font-medium text-muted"> | |
| ⋯ | |||
| 108 | 116 | </h2> | |
| 109 | 117 | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line"> | |
| 110 | 118 | {teams.map((team) => ( | |
| 111 | − | <TeamRow key={team.id} team={team} /> | |
| 119 | + | <TeamRow key={team.id} team={team} agents={agents[team.slug]} /> | |
| 112 | 120 | ))} | |
| 113 | 121 | </ul> | |
| 114 | 122 | </section> | |
| 64 | 64 | return ( | |
| 65 | 65 | <div className="space-y-8"> | |
| 66 | 66 | <div className="grid grid-cols-1 gap-3 sm:grid-cols-3"> | |
| 67 | − | <Tile icon={<Users size={12} />} label="Members" value={members ? String(members.length) : "—"} to={`/${slug}/-/people`} action={role === "owner" ? "Manage people" : "See everyone"} /> | |
| 67 | + | <Tile icon={<Users size={12} />} label="Members" value={members ? String(members.length) : "—"} to={role === "owner" ? `/${slug}/-/members` : `/${slug}/-/people`} action={role === "owner" ? "Manage people" : "See everyone"} /> | |
| 68 | 68 | <Tile icon={<Sparkles size={12} />} label="Agents" value={agents == null ? "—" : String(agents)} to={`/${slug}/-/agents`} action="Open Agents" /> | |
| 69 | 69 | <Tile icon={<Box size={12} />} label="Projects" value={projects == null ? "—" : String(projects)} to={`/${slug}/-/projects`} action="Open Code" /> | |
| 70 | 70 | </div> | |
| ⋯ | |||
| 108 | 108 | <section className="self-start rounded-xl border border-line bg-surface p-5"> | |
| 109 | 109 | <div className="flex items-center justify-between"> | |
| 110 | 110 | <h2 className="font-medium">Members</h2> | |
| 111 | − | <Link to={`/${slug}/-/people`} className="text-xs text-muted hover:text-fg"> | |
| 111 | + | <Link to={role === "owner" ? `/${slug}/-/members` : `/${slug}/-/people`} className="text-xs text-muted hover:text-fg"> | |
| 112 | 112 | {role === "owner" ? "Manage" : "See all"} | |
| 113 | 113 | </Link> | |
| 114 | 114 | </div> | |
| 425 | 425 | secret alert `admin`, turning security updates on or off `maintain`; | |
| 426 | 426 | enabling or disabling a workflow needs `maintain`; plans and project memory | |
| 427 | 427 | are read by anyone who can read the repository, and project memory is | |
| 428 | − | changed with `write`; workspace memory is for members. People: the | |
| 429 | − | workspace's Settings → Members (`g1t.sh/<owner>/-/people`, with an Outside | |
| 430 | − | collaborators tab and the Base permission for owners); a repository's | |
| 428 | + | changed with `write`; workspace memory is for members. Members: the | |
| 429 | + | workspace's Members and invites (`g1t.sh/<owner>/-/members`, with an | |
| 430 | + | Outside collaborators tab and the Base permission for owners); a repository's | |
| 431 | 431 | Settings → Access (`g1t.sh/<owner>/<repo>/settings/access`); invitations | |
| 432 | 432 | are answered at `g1t.sh/<owner>/<repo>/invitations`. | |
| 433 | 433 | `GET {repo}/collaborators/{username}/permission` gives a role and what it | |
| ⋯ | |||
| 441 | 441 | `team_reviewers`), and the team may pick who. Guide: | |
| 442 | 442 | https://docs.g1t.sh/guides/teams/ | |
| 443 | 443 | ||
| 444 | + | A team can hold people, agents or both, with a lead, a Chat channel and a | |
| 445 | + | monthly budget its agents share. Every reply and session step, an agent is | |
| 446 | + | told each visible team it is on: who leads it, each person's title, what | |
| 447 | + | they own, who they report to, whether they are around and their local | |
| 448 | + | time, and who to ask when a person is needed. Ask the person who owns | |
| 449 | + | something before guessing. The directory of people and agents is | |
| 450 | + | `g1t.sh/<owner>/-/people`, profiles `-/people/<username>` and | |
| 451 | + | `-/people/agents/<handle>`, the org chart `-/org-chart`. Guide: | |
| 452 | + | https://docs.g1t.sh/guides/people-and-teams/ | |
| 453 | + | ||
| 444 | 454 | A CODEOWNERS file (`.g1t/CODEOWNERS`, `.github/CODEOWNERS`, `CODEOWNERS`, | |
| 445 | 455 | `docs/CODEOWNERS` or `.gitlab/CODEOWNERS`, the first found on the default | |
| 446 | 456 | branch) asks owners to review pull requests that change their files. With | |
| ⋯ | |||
| 759 | 769 | - [Workspaces and tokens](https://docs.g1t.sh/guides/workspaces/) | |
| 760 | 770 | - [Access and roles](https://docs.g1t.sh/guides/access-and-roles/) | |
| 761 | 771 | - [Teams](https://docs.g1t.sh/guides/teams/) | |
| 772 | + | - [People and teams](https://docs.g1t.sh/guides/people-and-teams/) | |
| 762 | 773 | - [CODEOWNERS](https://docs.g1t.sh/guides/codeowners/) | |
| 763 | 774 | - [Managing a repository](https://docs.g1t.sh/guides/managing-repositories/) | |
| 764 | 775 | - [Integrations](https://docs.g1t.sh/guides/integrations/) | |
| 32 | 32 | mod names; | |
| 33 | 33 | mod outcome; | |
| 34 | 34 | pub mod packages; | |
| 35 | + | pub mod people; | |
| 35 | 36 | pub mod projects; | |
| 36 | 37 | pub mod repos; | |
| 37 | 38 | pub mod rules; |
| 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 | |
| 4 | + | //! agents service, which identity names only by id. | |
| 5 | + | //! | |
| 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. | |
| 10 | + | //! | |
| 11 | + | //! **Agents on teams.** A team can have agents as well as people: mixed, | |
| 12 | + | //! people only or agents only. An agent is on a team when it was added to | |
| 13 | + | //! it (`set_team_agent`) and on the team its own profile names, its home | |
| 14 | + | //! team. Agents are told who is on their visible teams every turn | |
| 15 | + | //! (`agent_teams`). | |
| 16 | + | //! | |
| 17 | + | //! Every method is served by identity at `POST /rpc/<method>`. | |
| 18 | + | ||
| 19 | + | use serde::{Deserialize, Serialize}; | |
| 20 | + | ||
| 21 | + | use crate::teams::{TeamChannel, TeamLead, TeamRef, TeamRole, TeamVisibility}; | |
| 22 | + | use crate::{Role, User}; | |
| 23 | + | ||
| 24 | + | /// The longest title. | |
| 25 | + | pub const MAX_TITLE_LENGTH: usize = 80; | |
| 26 | + | /// The most things one person owns, as their profile lists them. | |
| 27 | + | pub const MAX_OWNS: usize = 8; | |
| 28 | + | /// The longest of them. | |
| 29 | + | 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 | + | ||
| 33 | + | /// One member, as the directory, their profile and the org chart show them. | |
| 34 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 35 | + | pub struct DirectoryPerson { | |
| 36 | + | pub user_id: String, | |
| 37 | + | pub username: String, | |
| 38 | + | /// The username as its owner wrote it, when that differs. | |
| 39 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 40 | + | pub display_username: Option<String>, | |
| 41 | + | pub name: Option<String>, | |
| 42 | + | pub avatar: Option<String>, | |
| 43 | + | /// From their public profile. | |
| 44 | + | pub bio: Option<String>, | |
| 45 | + | pub location: Option<String>, | |
| 46 | + | pub pronouns: Option<String>, | |
| 47 | + | /// An IANA name, such as `America/Denver`, for their local time. | |
| 48 | + | pub timezone: Option<String>, | |
| 49 | + | /// Owner or member. | |
| 50 | + | pub role: Role, | |
| 51 | + | /// Their title in this workspace. | |
| 52 | + | pub title: Option<String>, | |
| 53 | + | /// Who they report to, by username. | |
| 54 | + | pub manager: Option<String>, | |
| 55 | + | /// What they own: a few short phrases. | |
| 56 | + | pub owns: Vec<String>, | |
| 57 | + | /// When they joined the workspace (RFC 3339). | |
| 58 | + | pub joined_at: String, | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /// One person on a team, as the directory names them. | |
| 62 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 63 | + | pub struct TeamPersonRef { | |
| 64 | + | pub username: String, | |
| 65 | + | pub role: TeamRole, | |
| 66 | + | } | |
| 67 | + | ||
| 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. | |
| 70 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 71 | + | pub struct DirectoryTeam { | |
| 72 | + | pub slug: String, | |
| 73 | + | pub name: String, | |
| 74 | + | pub description: Option<String>, | |
| 75 | + | pub visibility: TeamVisibility, | |
| 76 | + | pub parent: Option<TeamRef>, | |
| 77 | + | pub lead: Option<TeamLead>, | |
| 78 | + | pub channel: Option<TeamChannel>, | |
| 79 | + | pub budget_micros: Option<i64>, | |
| 80 | + | /// Its own people, not its child teams'. | |
| 81 | + | pub people: Vec<TeamPersonRef>, | |
| 82 | + | /// The agents added to it, by id. Agents whose home team it is are | |
| 83 | + | /// on it too; the agents service names those. | |
| 84 | + | pub agent_ids: Vec<String>, | |
| 85 | + | /// Repositories it has a role on itself. | |
| 86 | + | pub repos_count: u32, | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /// `people_directory`: everyone in a workspace and its teams, as the | |
| 90 | + | /// viewer may see them (secret teams only for their people and owners). | |
| 91 | + | /// Members only. Returns `Outcome<PeopleDirectory>`. | |
| 92 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 93 | + | pub struct PeopleArgs { | |
| 94 | + | pub viewer: crate::Viewer, | |
| 95 | + | pub workspace: String, | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 99 | + | pub struct PeopleDirectory { | |
| 100 | + | pub people: Vec<DirectoryPerson>, | |
| 101 | + | pub teams: Vec<DirectoryTeam>, | |
| 102 | + | /// What members get on every repository: `none`, `read`, `write` or `admin`. | |
| 103 | + | pub base_permission: String, | |
| 104 | + | /// Whether the viewer owns the workspace: they can set anyone's | |
| 105 | + | /// title, manager and what they own. | |
| 106 | + | pub can_manage: bool, | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | /// `set_member_profile`: what is given changes; the rest stays. `manager` | |
| 110 | + | /// set to an empty string clears it. Returns `Outcome<DirectoryPerson>`. | |
| 111 | + | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 112 | + | pub struct SetMemberProfileArgs { | |
| 113 | + | pub actor: User, | |
| 114 | + | pub workspace: String, | |
| 115 | + | pub username: String, | |
| 116 | + | #[serde(default)] | |
| 117 | + | pub title: Option<String>, | |
| 118 | + | /// A member's username, or an empty string for none. Owners only. | |
| 119 | + | #[serde(default)] | |
| 120 | + | pub manager: Option<String>, | |
| 121 | + | #[serde(default)] | |
| 122 | + | pub owns: Option<Vec<String>>, | |
| 123 | + | #[serde(default)] | |
| 124 | + | pub surface: Option<crate::audit::Surface>, | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | /// A title as stored: trimmed, at most [`MAX_TITLE_LENGTH`] characters, | |
| 128 | + | /// `None` when nothing is left. | |
| 129 | + | pub fn clean_title(title: &str) -> Option<String> { | |
| 130 | + | let title: String = title.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(MAX_TITLE_LENGTH).collect(); | |
| 131 | + | let title = title.trim().to_owned(); | |
| 132 | + | (!title.is_empty()).then_some(title) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | /// What someone owns, as stored: each phrase trimmed and cut to | |
| 136 | + | /// [`MAX_OWNS_LENGTH`], blanks and repeats (ignoring case) left out, at | |
| 137 | + | /// most [`MAX_OWNS`]. | |
| 138 | + | pub fn clean_owns(owns: &[String]) -> Vec<String> { | |
| 139 | + | let mut out: Vec<String> = Vec::new(); | |
| 140 | + | for phrase in owns { | |
| 141 | + | let phrase: String = phrase.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(MAX_OWNS_LENGTH).collect(); | |
| 142 | + | let phrase = phrase.trim().trim_end_matches(['.', ',', ';']).trim().to_owned(); | |
| 143 | + | if phrase.is_empty() || out.iter().any(|kept| kept.eq_ignore_ascii_case(&phrase)) { | |
| 144 | + | continue; | |
| 145 | + | } | |
| 146 | + | out.push(phrase); | |
| 147 | + | if out.len() == MAX_OWNS { | |
| 148 | + | break; | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | out | |
| 152 | + | } | |
| 153 | + | ||
| 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. | |
| 156 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 157 | + | pub enum ProfileField { | |
| 158 | + | Title, | |
| 159 | + | Owns, | |
| 160 | + | Manager, | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | /// Whether `actor` may change `field` on someone's profile. | |
| 164 | + | pub fn may_edit_profile(field: ProfileField, is_self: bool, owner: bool) -> bool { | |
| 165 | + | match field { | |
| 166 | + | ProfileField::Title | ProfileField::Owns => is_self || owner, | |
| 167 | + | ProfileField::Manager => owner, | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 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 | + | #[cfg(test)] | |
| 190 | + | mod tests { | |
| 191 | + | use super::*; | |
| 192 | + | use std::collections::HashMap; | |
| 193 | + | ||
| 194 | + | #[test] | |
| 195 | + | fn titles_are_tidied() { | |
| 196 | + | assert_eq!(clean_title(" Staff Engineer "), Some("Staff Engineer".to_owned())); | |
| 197 | + | assert_eq!(clean_title(" "), None); | |
| 198 | + | assert_eq!(clean_title(&"a".repeat(200)).map(|t| t.len()), Some(MAX_TITLE_LENGTH)); | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | #[test] | |
| 202 | + | fn what_someone_owns_is_kept_short_and_once() { | |
| 203 | + | let owns = clean_owns(&[ | |
| 204 | + | " storefront ".into(), | |
| 205 | + | "Storefront".into(), | |
| 206 | + | "".into(), | |
| 207 | + | "the release process.".into(), | |
| 208 | + | "x".repeat(100), | |
| 209 | + | ]); | |
| 210 | + | assert_eq!(owns[0], "storefront"); | |
| 211 | + | assert_eq!(owns[1], "the release process"); | |
| 212 | + | assert_eq!(owns[2].len(), MAX_OWNS_LENGTH); | |
| 213 | + | assert_eq!(owns.len(), 3); | |
| 214 | + | let many: Vec<String> = (0..20).map(|i| format!("thing {i}")).collect(); | |
| 215 | + | assert_eq!(clean_owns(&many).len(), MAX_OWNS); | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | #[test] | |
| 219 | + | fn people_write_their_own_and_owners_set_managers() { | |
| 220 | + | assert!(may_edit_profile(ProfileField::Title, true, false)); | |
| 221 | + | assert!(may_edit_profile(ProfileField::Owns, false, true)); | |
| 222 | + | 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 | + | } | |
| 237 | + | } |
| 238 | 238 | } | |
| 239 | 239 | } | |
| 240 | 240 | ||
| 241 | + | /// Who leads a team: a person on it, or an agent on it. | |
| 242 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 243 | + | #[serde(tag = "kind", rename_all = "snake_case")] | |
| 244 | + | pub enum TeamLead { | |
| 245 | + | User { | |
| 246 | + | username: String, | |
| 247 | + | #[serde(default)] | |
| 248 | + | name: Option<String>, | |
| 249 | + | #[serde(default)] | |
| 250 | + | avatar: Option<String>, | |
| 251 | + | }, | |
| 252 | + | /// An agent, by its id in the agents service. | |
| 253 | + | Agent { agent_id: String }, | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | /// A lead as written to `update_team`: `@username` (or a bare username) | |
| 257 | + | /// for a person, `agent:<id>` for an agent. | |
| 258 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 259 | + | pub enum LeadInput { | |
| 260 | + | User(String), | |
| 261 | + | Agent(String), | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | /// `lead` as `update_team` takes it; `None` when it is not one. An empty | |
| 265 | + | /// string means no lead, and is not parsed here. | |
| 266 | + | pub fn parse_lead(text: &str) -> Option<LeadInput> { | |
| 267 | + | let text = text.trim(); | |
| 268 | + | if let Some(id) = text.strip_prefix("agent:") { | |
| 269 | + | let id = id.trim(); | |
| 270 | + | let ok = !id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'); | |
| 271 | + | return ok.then(|| LeadInput::Agent(id.to_owned())); | |
| 272 | + | } | |
| 273 | + | let name = text.trim_start_matches('@').to_lowercase(); | |
| 274 | + | crate::is_valid_namespace(&name).then_some(LeadInput::User(name)) | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | /// A team's chat channel. | |
| 278 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 279 | + | pub struct TeamChannel { | |
| 280 | + | /// Its id in the chat service. | |
| 281 | + | pub id: String, | |
| 282 | + | /// Its name, without `#`, as it was when chosen. | |
| 283 | + | pub name: String, | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | /// One agent on a team. | |
| 287 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 288 | + | pub struct TeamAgent { | |
| 289 | + | pub agent_id: String, | |
| 290 | + | /// Who added it, by username; null when its account is gone. | |
| 291 | + | pub added_by: Option<String>, | |
| 292 | + | /// RFC 3339. | |
| 293 | + | pub created_at: String, | |
| 294 | + | } | |
| 295 | + | ||
| 241 | 296 | /// A team as another names it: its parent, or a child. | |
| 242 | 297 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 243 | 298 | pub struct TeamRef { | |
| ⋯ | |||
| 264 | 319 | /// Repositories it has a role on itself, not counting inherited ones. | |
| 265 | 320 | pub repos_count: u32, | |
| 266 | 321 | pub child_teams_count: u32, | |
| 322 | + | /// Agents added to it. Agents whose home team it is are on it too. | |
| 323 | + | #[serde(default)] | |
| 324 | + | pub agents_count: u32, | |
| 325 | + | /// Who leads it, if anyone. | |
| 326 | + | #[serde(default)] | |
| 327 | + | pub lead: Option<TeamLead>, | |
| 328 | + | /// Its chat channel, if it has one. | |
| 329 | + | #[serde(default)] | |
| 330 | + | pub channel: Option<TeamChannel>, | |
| 331 | + | /// What its agents may spend together in a calendar month, in | |
| 332 | + | /// millionths of a dollar; null for no team budget. | |
| 333 | + | #[serde(default)] | |
| 334 | + | pub budget_micros: Option<i64>, | |
| 267 | 335 | /// The viewer's place in it, if any. | |
| 268 | 336 | pub viewer_role: Option<TeamRole>, | |
| 269 | 337 | /// Whether the viewer may change it: an owner of the workspace, or one | |
| ⋯ | |||
| 468 | 536 | pub notify: Option<bool>, | |
| 469 | 537 | #[serde(default)] | |
| 470 | 538 | pub review_assignment: Option<ReviewAssignment>, | |
| 539 | + | /// Who leads it: `@username` or `agent:<id>`, someone on the team; an | |
| 540 | + | /// empty string for no lead. | |
| 541 | + | #[serde(default)] | |
| 542 | + | pub lead: Option<String>, | |
| 543 | + | /// Its chat channel's id in the chat service; an empty string for none. | |
| 544 | + | #[serde(default)] | |
| 545 | + | pub channel_id: Option<String>, | |
| 546 | + | /// That channel's name, without `#`; given with `channel_id`. | |
| 547 | + | #[serde(default)] | |
| 548 | + | pub channel_name: Option<String>, | |
| 549 | + | /// What its agents may spend together in a month, in millionths of a | |
| 550 | + | /// dollar; 0 for no team budget. | |
| 551 | + | #[serde(default)] | |
| 552 | + | pub budget_micros: Option<i64>, | |
| 471 | 553 | #[serde(default)] | |
| 472 | 554 | pub surface: Option<crate::audit::Surface>, | |
| 473 | 555 | } | |
| 474 | 556 | ||
| 557 | + | /// `set_team_agent`: adds one of the workspace's agents to a team. The | |
| 558 | + | /// caller checks the agent is the workspace's (identity knows agents only | |
| 559 | + | /// by id). Owners and the team's maintainers. Returns `Outcome<TeamAgent>`. | |
| 560 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 561 | + | pub struct SetTeamAgentArgs { | |
| 562 | + | pub actor: User, | |
| 563 | + | pub workspace: String, | |
| 564 | + | pub team: String, | |
| 565 | + | pub agent_id: String, | |
| 566 | + | #[serde(default)] | |
| 567 | + | pub surface: Option<crate::audit::Surface>, | |
| 568 | + | } | |
| 569 | + | ||
| 570 | + | /// `remove_team_agent`: takes an agent off a team; if it led the team, | |
| 571 | + | /// the team has no lead after. Owners and the team's maintainers. | |
| 572 | + | /// Returns `Outcome<bool>`. | |
| 573 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 574 | + | pub struct RemoveTeamAgentArgs { | |
| 575 | + | pub actor: User, | |
| 576 | + | pub workspace: String, | |
| 577 | + | pub team: String, | |
| 578 | + | pub agent_id: String, | |
| 579 | + | #[serde(default)] | |
| 580 | + | pub surface: Option<crate::audit::Surface>, | |
| 581 | + | } | |
| 582 | + | ||
| 583 | + | /// `agent_teams`: for the agents service. The visible teams an agent is | |
| 584 | + | /// on in a workspace (added to, or its home team), each with everyone on | |
| 585 | + | /// it, for what the agent is told every turn and its team budgets. | |
| 586 | + | /// Returns `Vec<AgentTeam>`. | |
| 587 | + | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 588 | + | pub struct AgentTeamsArgs { | |
| 589 | + | /// The workspace's slug. | |
| 590 | + | pub workspace: String, | |
| 591 | + | pub agent_id: String, | |
| 592 | + | /// The team its profile names, by slug. | |
| 593 | + | #[serde(default)] | |
| 594 | + | pub home_team: Option<String>, | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | /// A person on a team, as an agent on it is told of them. | |
| 598 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 599 | + | pub struct RosterPerson { | |
| 600 | + | pub user_id: String, | |
| 601 | + | pub username: String, | |
| 602 | + | pub name: Option<String>, | |
| 603 | + | pub title: Option<String>, | |
| 604 | + | pub timezone: Option<String>, | |
| 605 | + | pub owns: Vec<String>, | |
| 606 | + | /// Who they report to, by username. | |
| 607 | + | pub manager: Option<String>, | |
| 608 | + | pub maintainer: bool, | |
| 609 | + | } | |
| 610 | + | ||
| 611 | + | /// A team an agent is on, as it is told of it. | |
| 612 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 613 | + | pub struct AgentTeam { | |
| 614 | + | pub slug: String, | |
| 615 | + | pub name: String, | |
| 616 | + | pub description: Option<String>, | |
| 617 | + | pub lead: Option<TeamLead>, | |
| 618 | + | pub channel: Option<TeamChannel>, | |
| 619 | + | pub budget_micros: Option<i64>, | |
| 620 | + | pub people: Vec<RosterPerson>, | |
| 621 | + | /// The agents added to it, by id. | |
| 622 | + | pub agent_ids: Vec<String>, | |
| 623 | + | } | |
| 624 | + | ||
| 475 | 625 | /// `delete_team`: its child teams move up to its parent, and the roles it | |
| 476 | 626 | /// gave go with it. Owners and the team's maintainers. Returns | |
| 477 | 627 | /// `Outcome<bool>`. | |
| ⋯ | |||
| 634 | 784 | } | |
| 635 | 785 | ||
| 636 | 786 | #[test] | |
| 787 | + | fn leads_are_people_or_agents() { | |
| 788 | + | assert_eq!(parse_lead("@Priya"), Some(LeadInput::User("priya".into()))); | |
| 789 | + | assert_eq!(parse_lead("priya"), Some(LeadInput::User("priya".into()))); | |
| 790 | + | assert_eq!(parse_lead("agent:agt_123"), Some(LeadInput::Agent("agt_123".into()))); | |
| 791 | + | assert_eq!(parse_lead("agent:"), None); | |
| 792 | + | assert_eq!(parse_lead("agent:a b"), None); | |
| 793 | + | assert_eq!(parse_lead("not a name"), None); | |
| 794 | + | let lead = TeamLead::Agent { agent_id: "agt_1".into() }; | |
| 795 | + | assert_eq!(serde_json::to_value(&lead).unwrap(), serde_json::json!({ "kind": "agent", "agent_id": "agt_1" })); | |
| 796 | + | let lead: TeamLead = serde_json::from_value(serde_json::json!({ "kind": "user", "username": "ana" })).unwrap(); | |
| 797 | + | assert_eq!(lead, TeamLead::User { username: "ana".into(), name: None, avatar: None }); | |
| 798 | + | } | |
| 799 | + | ||
| 800 | + | #[test] | |
| 637 | 801 | fn handles_name_a_workspace_and_a_team() { | |
| 638 | 802 | assert_eq!(parse_handle("@acme/backend"), Some(("acme".into(), "backend".into()))); | |
| 639 | 803 | assert_eq!(parse_handle("@Acme/Backend"), Some(("acme".into(), "backend".into()))); | |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.