Skip to content

Commit

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.

syntaqxcommitted Parent3d2a1d7Browse files
72 files+3360−8490/72 viewed
+4−0
54275427 parent: words("parent"),
54285428 notify: if self == Op::UpdateTeam { yes(input, "notify") } else { None },
54295429 review_assignment: review,
5430+ lead: None,
5431+ channel_id: None,
5432+ channel_name: None,
5433+ budget_micros: None,
54305434 surface: Some(services.audit.surface),
54315435 };
54325436 if args.name.is_none()
+1−0
178178 { label: 'Workspaces and tokens', slug: 'guides/workspaces' },
179179 { label: 'Access and roles', slug: 'guides/access-and-roles' },
180180 { label: 'Teams', slug: 'guides/teams' },
181+ { label: 'People and teams', slug: 'guides/people-and-teams' },
181182 { label: 'Managing a repository', slug: 'guides/managing-repositories' },
182183 { label: 'Releases', slug: 'guides/releases' },
183184 { label: 'Transferring a repository', slug: 'guides/transferring-repositories' },
+2−2
147147
148148 Owners always have Admin, whatever it says. Only owners can change it:
149149
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.
151151 2. Under **Base permission**, choose one.
152152
153153 It takes effect on everyone's next request. To give one member more on
253253
254254 Owners see every outside collaborator, and the repositories and roles each
255255 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
257257 workspace as a member (see [add people](/guides/workspaces/#add-people));
258258 once they accept, the roles they have stay, and the base permission adds
259259 to them.
+7−1
11 ---
22 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.
44 ---
55
66 Agents are for everyone in the company: engineers, support, sales. Their
1818 | **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. |
1919 | **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. |
2020 | **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.* |
2122 | **An agent's own budget** | Owners, on the agent's **Profile**. | Monthly, and optionally daily. At 100% that agent takes no new work. |
2223 | **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. |
2324
2425 Months and days are UTC. An idle agent costs nothing.
2526
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+
2632 ## The agent budget
2733
2834 Owners set it from the **Budget** button on the Agents page:
+17−8
166166 with is never lost.
167167
168168 <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
171175 team does, comes next. <Soon />
172176 </Aside>
173177
282286 ## Agents know each other
283287
284288 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.
287296
288297 | Move | What happens | Example |
289298 | --- | --- | --- |
573582 summary of what it shipped, what it is waiting on and what it spent.
574583 <Soon />
575584 </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 />
580589 </Card>
581590 <Card title="Create one in chat" icon="wand-sparkles">
582591 Describe the agent you want in a message, and confirm the draft card g1t
+11−8
127127
128128 | | Invite to g1t | Invite to a workspace |
129129 | --- | --- | --- |
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 |
131131 | 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 |
132132 | Someone without an account | Makes their account with it | Makes their account with it too, while g1t is invite-only, then answers the invitation |
133133 | Someone already on g1t | Nothing: they have an account | The invitation, in their notifications and by email |
228228 - **It makes your own account.** Each person who uses it gets a new
229229 account, and then makes their own workspace. It does not add you to
230230 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.
232232 - **It may be for some email domains only.** When it is, the email field
233233 says which, such as `example.com`, and sign-up takes only an address
234234 there. Use your address at that organization; you confirm it like any
269269 are in.
270270
271271 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
273274 [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.
275277
276278 Once anyone can sign up for g1t, there are no invites to g1t to make:
277279 Settings → Invites keeps only the list of invites you already made, and
278280 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
280282 in your settings or account menu.
281283
282284 Each person can have **5** invites out at a time. Pending and used invites
291293
292294 ### Inviting someone into a workspace
293295
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
295298 username or by email address, with the role they join as; see
296299 [add people](/guides/workspaces/#add-people). Nobody is added without
297300 saying yes: someone on g1t gets a [workspace invitation](#workspace-invitations)
314317
315318 An invitation works for 30 days, the same as an invite. Until it is
316319 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
318321 people, so an invitation to one cannot be accepted until it starts the
319322 plan. Only you can answer your invitations: an agent's token and a
320323 workspace's token cannot.
755758 | SSH keys | Settings → SSH keys |
756759 | Applications you signed in to, and signing in with GitHub | Settings → Connected applications, Settings → GitHub |
757760 | 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 |
759762 | Payment methods: the billing portal, adding a card, subscribing and buying AI credit | A workspace's Billing |
760763
761764 ### Permissions
+1−1
537537 Everyone you share a workspace with can see whether you're here, and what
538538 you've said about yourself, wherever your name shows: beside a direct
539539 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.
541541 It all moves live; nobody has to reload.
542542
543543 | The dot on an avatar | It means |
+225−0
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.
+4−0
6868 | **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. |
6969 | **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. |
7070
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+
7175 Work already running finishes, so spend can go slightly past a budget.
7276
7377 ### A person's budget
+91−11
11 ---
22 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.
44 ---
55
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
912 `@acme/backend` in a comment and everyone in it hears of it, or ask it to
1013 review a pull request and it decides who looks.
1114
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/).
1420
1521 ## Create a team
1622
4046 | Teams in one workspace | Up to 500. |
4147
4248 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.
4554
4655 ### Who can create teams
4756
8695 | See a secret team | Its own people and the workspace's owners |
8796 | Create a team | Any member. Under a parent: an owner, or a maintainer of the parent |
8897 | 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 |
89100 | Change its name, slug, description, visibility, notifications and review assignment | Owners, and the team's maintainers |
90101 | Move it under another team | Owners, or someone who maintains both teams |
91102 | Delete it | Owners, and the team's maintainers |
99110
100111 To manage the people in a team:
101112
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.
104115 2. Add someone by username. They must already be a member of the
105116 workspace.
106117 3. Beside a person, choose **Make maintainer** (or **Make member**), or
109120 Turn on **Include the people of child teams** to also list the people of its
110121 [child teams](#nesting), each with the child team they are in.
111122
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+
112191 ## Nesting
113192
114193 A team can have a **parent**. Choose one when you create it, or under the
297376 ## Settings
298377
299378 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
301381 assignment**, and **Delete team**.
302382
303383 Changing the slug changes how the team is mentioned and its address. A
+1−1
7676 waits, and works once the plan is on).
7777
7878 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
8080 **Settings → Access**, an owner sees **Start the plan to invite people**
8181 with a button to the plan. Through the API and MCP, adding a member,
8282 inviting, adding an outside collaborator and accepting are refused with
+7−0
273273 same time can also ask each other questions and hand each other work. See
274274 [talk to agents](/guides/talking-to-agents/).
275275
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+
276283 ### Merging automatically
277284
278285 A repository can land g1t's pull request by itself once it is
+24−19
303303
304304 ### Change someone's role
305305
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:
307308
308309 1. **Make owner** or **Make member** changes their role.
309310 2. **Billing manager** and **Security manager** turn each role on or off.
321322
322323 ### Leave a workspace
323324
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
326327 and your place in its teams go with you at once. The only owner cannot
327328 leave.
328329
337338
338339 | | Invite to a workspace | Invite to g1t |
339340 | --- | --- | --- |
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) |
341342 | What they get | An invitation to join the workspace, to accept or decline | One new account, in no workspace but its own |
342343 | Without an account | The invitation lets them sign up first, while g1t is invite-only | It lets them sign up |
343344 | When | Always | Only while g1t is invite-only |
344345
345346 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.
348349
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):
351352
352353 1. Under **Invite to** *workspace name*, type a username, a name or an email address.
353354 As you type, people on g1t are offered by username and name, with their
396397
397398 **A free workspace cannot add people.** Until it starts the g1t plan, it
398399 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
401402 form, and the API and MCP answer `402` (`payment_required`). See
402403 [who a free workspace can add](/guides/usage-and-billing/#who-a-free-workspace-can-add).
403404
486487 | **Projects** | `/-/projects` | Everyone | Every project you can see. See [the Projects page](#the-projects-page). |
487488 | [**Packages**](/guides/packages/) | `/-/packages` | Everyone | What the workspace publishes. A visitor opens it from **Packages** on the workspace's page. |
488489 | [**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. |
490493 | **Insights** | `/-/insights` | Members | Coming soon: how the whole workspace delivers. |
491494 | **Settings** | `/-/settings` | Owners | How the workspace is set up and connected (below). |
492495
495498 or a visitor sees the public ones and those shared with them, without the
496499 workspace's people, deployments or settings.
497500
498−Older addresses still work: `/-/members` opens People, and
499501 `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.
501503
502504 ### The Projects page
503505
552554 | **Agents** | The workspace's agents and their sessions. See [agents](/guides/agents/). |
553555 | **Code** | Projects, pull requests and checks. Not shown to a member without [Code access](/guides/agent-access/). |
554556 | **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/). |
556558 | **Workspace** | Usage, billing, integrations, policies and settings. |
557559
558560 A bar on the dock's edge marks the app you are in. Your account is the
602604 extensions are published. An app you can't use shows **Request access**.
603605 The built-in apps' own pages are in their sidebars, not in Apps: Code's has
604606 **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**,
606609 **AI Gateway**, **Integrations** and the **Audit log**.
607610
608611 A pinned app shows as its mark under the built-in ones, in the order you
660663 | **Actions** | Members | How workflows run in the workspace. |
661664 | **Runners** | Owners | The workspace's self-hosted machines, their groups and registration tokens. |
662665
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).
667672
668673 Below 1024px wide, the sidebar opens over the page from the left, from the
669674 header's button, and closes when you open a page.
+7−3
6464 | [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" /> |
6565 | [Chat](/guides/chat/) | Channels, direct messages and threads, live. People and agents are members alike. No seats and no history limit. | <Status is="live" /> |
6666 | [Notifications](/guides/notifications/) | Everything waiting on you: mentions, reviews, approvals and alerts, with browser push. | <Status is="live" /> |
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" /> |
6968 | [Artifacts](/guides/artifacts/) | Documents written together, live, by people and agents. Private until you share them. | <Status is="live" /> |
7069 | More kinds of artifact | Slides, designs and dashboards that keep the query behind every number. | <Status is="coming" /> |
7170 | Scratchpads | Tools, reports and agents anyone can build on a copy of the data, used in production only after review. | <Status is="coming" /> |
195194 />
196195 <LinkCard
197196 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."
199198 href="/guides/teams/"
200199 />
201200 <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
202206 title="Report deployments"
203207 description="See every deployment and environment in one place, report deployments from any CI, and require one before a merge."
204208 href="/guides/deployments-api/"
+1−1
7373 </p>
7474 <p className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs">
7575 {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">
7777 Make someone else an owner
7878 </Link>
7979 ) : null}
+143−0
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+}
+15−2
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";
22 import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
33 import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
44
12651265 );
12661266 }
12671267
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+ */
12691272 function PeopleSidebar({ slug }: { slug: string }) {
12701273 const { pathname } = useLocation();
12711274 const going = useNavigation().location?.pathname;
12821285 <SidebarLink to={`/${slug}/-/teams`} icon={<UsersRound size={15} />} current={at("teams")}>
12831286 Teams
12841287 </SidebarLink>
1288+ <SidebarLink to={`/${slug}/-/org-chart`} icon={<Network size={15} />} current={at("org-chart")}>
1289+ Org chart
1290+ </SidebarLink>
12851291 </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>
12861297 </nav>
12871298 </div>
12881299 );
13791390 secrets: "Secrets and variables",
13801391 settings: "Settings",
13811392 people: "People",
1393+ "org-chart": "Org chart",
1394+ members: "Members and invites",
13821395 projects: "Projects",
13831396 teams: "Teams",
13841397 tokens: "Access tokens",
+18−4
66
77 import { type Team, teamHandle } from "@g1t/contracts";
88
9+import type { PeopleAgent } from "../lib/people";
910 import { teamCounts, teamPath } from "../lib/teams";
1011 import { Badge } from "./ui/badge";
12+import { TeamKindMark } from "./people";
1113
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[] };
1420
1521 /** The team whose page this is. */
1622 export function useTeam(): Team {
1723 return useOutletContext<TeamContext>().team;
1824 }
1925
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+
2032 /** Secret, and the viewer's place in the team. */
2133 export function TeamBadges({ team }: { team: Pick<Team, "visibility" | "viewer_role"> }) {
2234 return (
3446 }
3547
3648 /** 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;
3851 return (
3952 <li className="flex flex-wrap items-start gap-3 px-4 py-3 transition-colors hover:bg-surface/60 sm:flex-nowrap">
4053 <span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
4255 </span>
4356 <div className="min-w-0 grow basis-48">
4457 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
58+ <TeamKindMark people={team.members_count} agents={agentsHere} />
4559 <Link to={teamPath(team.workspace, team.slug)} prefetch="intent" className="font-medium hover:underline">
4660 {team.name}
4761 </Link>
5064 </div>
5165 {team.description && <p className="mt-0.5 line-clamp-2 text-sm text-muted">{team.description}</p>}
5266 <p className="mt-1 text-xs text-faint">
53− {teamCounts(team)}
67+ {teamCounts(team, agentsHere)}
5468 {team.parent && (
5569 <>
5670 {" · in "}
+2−2
289289 "flagon-io",
290290 ),
291291 [
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" },
294294 ],
295295 );
296296 });
+1−1
346346 const here = current?.trim().toLowerCase() || null;
347347 return memberships
348348 .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` }))
350350 .sort((a, b) => Number(b.slug === here) - Number(a.slug === here));
351351 }
352352
+164−0
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+});
+214−0
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+}
+2−1
118118 "settings statement statement_entries status status_by_id suggest tree usage usage_meters user_by_username " +
119119 "user_for_session user_for_access_token usernames waiting_workspaces workflows workspace workspace_invites github_enabled " +
120120 "stars about public_links branch_drift tags last_commits languages contributors license releases release " +
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"
122123 ).split(" "),
123124 );
124125
+2−0
120120 test("counts leave out what is none", () => {
121121 assert.equal(teamCounts({ members_count: 1, repos_count: 0, child_teams_count: 2 }), "1 member · 2 child teams");
122122 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");
123125 });
124126
125127 test("who may create teams follows the workspace's setting", async () => {
+2−1
113113 }
114114
115115 /** "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 {
117117 const part = (n: number, one: string, many: string) => (n === 0 ? null : `${n} ${n === 1 ? one : many}`);
118118 const parts = [
119119 part(team.members_count, "member", "members"),
120+ part(agents, "agent", "agents"),
120121 part(team.repos_count, "repository", "repositories"),
121122 part(team.child_teams_count, "child team", "child teams"),
122123 ].filter(Boolean);
+2−2
125125 request(path, { method: "POST", headers: bearer("g1t_web"), body: new URLSearchParams(fields) });
126126 for (const [path, fields] of [
127127 ["/acme/-/settings.data", { intent: "delete" }],
128− ["/acme/-/people", { action: "transfer", member: "bob" }],
128+ ["/acme/-/members", { action: "transfer", member: "bob" }],
129129 ["/acme/-/billing.data", { intent: "portal" }],
130130 ["/acme/-/billing", { intent: "card-check" }],
131131 ["/acme/-/billing", { intent: "subscribe" }],
135135 }
136136 for (const [path, fields] of [
137137 ["/acme/-/settings", { intent: "rename", slug: "acme2" }],
138− ["/acme/-/people", { action: "role", member: "bob", role: "member" }],
138+ ["/acme/-/members", { action: "role", member: "bob", role: "member" }],
139139 ["/acme/-/billing", { intent: "budget" }],
140140 ] as const) {
141141 assert.equal((await tokenVerdict(post(path, fields), tokens)).kind, "signed-in", `${path} ${JSON.stringify(fields)}`);
+1−1
9797 // Deleting a workspace.
9898 { page: /^\/[^/]+\/-\/settings$/, field: "intent", values: ["delete"] },
9999 // Giving a workspace to another owner.
100− { page: /^\/[^/]+\/-\/people$/, field: "action", values: ["transfer"] },
100+ { page: /^\/[^/]+\/-\/(?:members|people)$/, field: "action", values: ["transfer"] },
101101 // Payment methods: the card on file, and the payment pages that take one.
102102 { page: /^\/[^/]+\/-\/billing$/, field: "intent", values: ["portal", "card-check", "subscribe", "buy-ai-credit"] },
103103 ];
+10−4
2222 });
2323
2424 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);
2627 assert.equal(workspaceRedirect("/acme/-/home", "?x=1"), "/acme/-/today?x=1");
2728 // Code's Overview lives at -/overview now: not an old address.
2829 assert.equal(workspaceRedirect("/acme/-/overview"), null);
4142 test("?tab= opens that page, keeping the rest of the query", () => {
4243 assert.equal(workspaceRedirect("/acme", "?tab=repositories"), "/acme/-/projects");
4344 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");
4547 assert.equal(workspaceRedirect("/acme", "?tab=overview"), "/acme");
4648 assert.equal(workspaceRedirect("/acme", "?tab=settings"), "/acme/-/settings");
4749 assert.equal(workspaceRedirect("/acme", "?tab=nonsense"), null);
5153 test("a click's data request is for the same page as a full load", () => {
5254 assert.equal(pagePath("/acme/-/insights.data"), "/acme/-/insights");
5355 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");
5658 });
5759
5860 test("an alias or old name leads to the same page under the workspace", () => {
9799 assert.equal(modeOf("/acme/-/security/settings", "acme"), "workspace");
98100 assert.equal(modeOf("/acme/-/people", "acme"), "people");
99101 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");
100106 assert.equal(modeOf("/acme/-/apps", "acme"), "apps");
101107 assert.equal(modeOf("/acme/-/billing", "acme"), "workspace");
102108 assert.equal(modeOf("/acme/-/tokens", "acme"), "workspace");
+2−2
5252 repos: "-/projects",
5353 packages: "-/packages",
5454 people: "-/people",
55− members: "-/people",
55+ members: "-/members",
5656 teams: "-/teams",
5757 insights: "-/insights",
5858 settings: "-/settings",
6161 /** Workspace pages that moved, by their old name under `-/`. */
6262 const MOVED: Record<string, string> = {
6363 home: "-/today",
64− members: "-/people",
6564 "soon/teams": "-/teams",
6665 "soon/insights": "-/insights",
6766 };
158157 people: "people",
159158 members: "people",
160159 teams: "people",
160+ "org-chart": "people",
161161 workspace: "workspace",
162162 spend: "workspace",
163163 usage: "workspace",
+6−2
9494 index("routes/workspace/overview.tsx"),
9595 // Its pages, each in the sidebar: Overview (above), Projects, People, Insights; Teams and Packages below.
9696 route("-/projects", "routes/workspace/projects.tsx"),
97+ // People mode: the directory of people and agents, each one's profile, and the org chart.
9798 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"),
98104 route("-/insights", "routes/workspace/tab-soon.tsx", { id: "routes/workspace/insights" }),
99105 // Pinning its projects, for the person signed in.
100106 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" }),
103107 // Code's Overview: Mission control's code panels, for this workspace.
104108 route("-/overview", "routes/workspace/code-overview.tsx"),
105109 // The workspace itself, at a glance: members, plan and spend.
+1−1
186186 </p>
187187 <p className="mt-2 text-muted">
188188 {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">
190190 Change the base permission
191191 </Link>
192192 ) : (
+9−1
5151 settings: { title: "General", about: "The workspace's name, icon, address and description, who can create teams, and deleting it." },
5252 people: {
5353 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",
5458 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.",
5559 },
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+ },
5664 projects: {
5765 title: "Projects",
5866 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.",
5967 },
6068 teams: {
6169 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.",
6371 },
6472 repositories: {
6573 title: "Repositories",
+685−0
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
11 /**
2− * Workspace pages that moved: `-/members` is People now, and `-/overview`
2+ * Workspace pages that moved: `-/home` is Today now, and `-/overview`
33 * the workspace's own page (lib/workspace-nav.ts).
44 */
55 import { data, redirect } from "react-router";
+129−0
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+}
+1−1
379379 <section>
380380 <div className="flex items-center justify-between">
381381 <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">
383383 {role === "owner" ? "Manage" : "See all"}
384384 </Link>
385385 </div>
+145−652
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";
173
184 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";
198 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";
5812
5913 export function meta({ params, ...args }: Route.MetaArgs) {
6014 return page(args, { title: `People · ${params.owner} · g1t` });
6115 }
6216
63−export async function loader({ params, context, request }: Route.LoaderArgs) {
17+export async function loader({ params, context }: Route.LoaderArgs) {
6418 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),
8224 ]);
8325 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) : [],
9529 };
9630 }
9731
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;
15236 }
15337
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+];
17743
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 };
19561
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;
20462 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>
224102 )}
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 ? (
238109 <>
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>
246114 </>
115+ ) : (
116+ "No one here yet."
247117 )}
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}.
459118 </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>
545119 ) : (
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>
547127 )}
548128 </div>
549129 );
550130 }
551131
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;
623160 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>
684177 );
685178 }
+210−0
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+}
+362−0
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+}
+87−9
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";
23 import { Link, Outlet, data } from "react-router";
34
4−import { teamHandle } from "@g1t/contracts";
5+import { teamHandle, teamKindLabel } from "@g1t/contracts";
56
67 import type { Route } from "./+types/layout";
78 import { page } from "../../../lib/meta";
89 import { TeamBadges, type TeamContext } from "../../../components/teams";
10+import { Coming, TeamKindMark } from "../../../components/people";
911 import { CopyLine, TabLink } from "../../../components/ui";
12+import { Hint } from "../../../components/ui/hint";
1013 import { TabStrip } from "../../../components/ui/tab-strip";
14+import { agentsOn, agentPath, peopleAgent, personPath } from "../../../lib/people";
1115 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";
1318 import { getViewer, roleIn } from "../../../lib/session.server";
1419
1520 export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
1924 export async function loader({ params, context }: Route.LoaderArgs) {
2025 const viewer = getViewer(context);
2126 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+ ]);
2332 // A secret team someone is not in looks like no team at all.
2433 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 };
2638 }
2739
2840 export default function TeamLayout({ loaderData }: Route.ComponentProps) {
29− const { team } = loaderData;
41+ const { team, agents, added } = loaderData;
3042 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);
3154 return (
3255 <div>
3356 <nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-1 text-sm text-muted">
5174 <TeamBadges team={team} />
5275 </div>
5376 {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>
54123 <div className="mt-3 max-w-xs">
55124 <CopyLine text={teamHandle(team)} />
56125 </div>
57126 <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
60129 </TabLink>
61130 <TabLink to={`${base}/teams`} icon={<Network size={15} />} count={team.child_teams_count}>
62131 Child teams
72141 </TabStrip>
73142 </header>
74143 <div className="pt-6">
75− <Outlet context={{ team } satisfies TeamContext} />
144+ <Outlet context={{ team, agents, added } satisfies TeamContext} />
76145 </div>
77146 </div>
78147 );
79148 }
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+}
+208−87
1−import { Users } from "lucide-react";
1+import { Bot, Sparkles, Users } from "lucide-react";
22 import { Form, Link, data, useSearchParams } from "react-router";
33
44 import type { TeamRole } from "@g1t/contracts";
55
66 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";
89 import { Avatar, ErrorText, Field, Input, SubmitButton } from "../../../components/ui";
910 import { Badge } from "../../../components/ui/badge";
1011 import { CheckboxOption } from "../../../components/ui/checkbox";
12+import { SelectField } from "../../../components/ui/select";
1113 import { Switch } from "../../../components/ui/switch";
14+import { WithPresence } from "../../../components/presence";
15+import { agentsOn, leads, peopleAgent, personPath, teamBlock } from "../../../lib/people";
1216 import { teamPath } from "../../../lib/teams";
13−import { identity } from "../../../lib/services.server";
17+import { identity, workspaceAgents } from "../../../lib/services.server";
1418 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server";
1519 import { UserCard } from "../../../components/user-card";
1620
1822 const viewer = getViewer(context);
1923 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
2024 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+ };
2345 }
2446
2547 export async function action({ request, params, context }: Route.ActionArgs) {
2749 const user = requireUser(context, request);
2850 const form = await request.formData();
2951 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+ }
3064 const username = String(form.get("username") ?? "").trim().replace(/^@/, "");
3165 if (!username) return { intent, username, error: "Enter a username." };
3266 if (intent === "remove") {
4074
4175 export default function TeamMembers({ loaderData, actionData }: Route.ComponentProps) {
4276 const team = useTeam();
43− const { members, children, me } = loaderData;
77+ const { agents, added } = useTeamAgents();
78+ const { members, children, me, addable, told } = loaderData;
4479 const [, setParams] = useSearchParams();
4580 const manage = team.can_manage;
4681 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;
4883
4984 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>
63103
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>
82131 </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+ )}
98149 <Form method="post">
99− <input type="hidden" name="intent" value="role" />
150+ <input type="hidden" name="intent" value="remove" />
100151 <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"}
104154 </SubmitButton>
105155 </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+ )}
123165
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>
133184
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…">
136240 Add
137241 </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>
138255 </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>
140262 )}
141− {actionData?.intent === "add" && <ErrorText>{actionData.error}</ErrorText>}
142263 </div>
143264 );
144265 }
+99−5
1212
1313 import type { Route } from "./+types/settings";
1414 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";
1619 import { Button, ErrorText, Field, Input, SubmitButton } from "../../../components/ui";
1720 import { Input as NumberInput } from "../../../components/ui/input";
1821 import { Textarea } from "../../../components/ui/textarea";
2124 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
2225 import { SwitchCard } from "../../../components/ui/switch";
2326 import { parentChoices, reviewAssignmentFromForm, teamChangesFromForm, teamPath } from "../../../lib/teams";
24−import { identity } from "../../../lib/services.server";
27+import { chat, identity, workspaceAgents } from "../../../lib/services.server";
2528 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server";
2629
2730 /** The parent select's value for "no parent". */
2831 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+}
2969
3070 export async function loader({ params, context }: Route.LoaderArgs) {
3171 const viewer = getViewer(context);
3272 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
3373 const team = unwrap(await identity.getTeam(viewer, params.owner, params.team));
3474 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+ ]);
3680 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 })) };
3882 }
3983
4084 export async function action({ request, params, context }: Route.ActionArgs) {
5397 const saved = await identity.updateTeam(user, params.owner, params.team, { review_assignment: review });
5498 return saved.ok ? { intent, error: null, saved: true } : { intent, error: saved.error.message, saved: false };
5599 }
100+ if (intent === "people") return saveLeadChannelBudget(form, params.owner, params.team, user);
56101 const changes = teamChangesFromForm(form);
57102 if (changes.parent === NO_PARENT) changes.parent = "";
58103 const saved = await identity.updateTeam(user, params.owner, params.team, changes);
64109
65110 export default function TeamSettings({ loaderData, actionData }: Route.ComponentProps) {
66111 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;
68115 const said = (intent: string) => (actionData?.intent === intent ? actionData : null);
69116 const [visibility, setVisibility] = useState<TeamVisibility>(team.visibility);
70117 const review = team.review_assignment;
132179 </Form>
133180 </section>
134181
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+
135229 <section aria-labelledby="review" className="border-t border-line pt-8">
136230 <h2 id="review" className="font-medium">
137231 Code review assignment
+18−10
88 import { TeamRow } from "../../components/teams";
99 import { ButtonLink, EmptyState, notACredential } from "../../components/ui";
1010 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";
1213 import { getViewer, roleIn, unwrap } from "../../lib/session.server";
1314
1415 export function meta({ params, ...args }: Route.MetaArgs) {
2021 // A workspace's teams are its members' business.
2122 const role = roleIn(viewer, params.owner);
2223 if (!role) throw data(null, { status: 404 });
23− const [teams, workspace] = await Promise.all([
24+ const [teams, workspace, directory, agents] = await Promise.all([
2425 identity.listTeams(viewer, params.owner).then(unwrap),
2526 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),
2630 ]);
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+ );
2735 // 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) };
2937 }
3038
3139 /** Searching changes only the address: the list is already here. */
3543 }
3644
3745 export default function WorkspaceTeams({ loaderData }: Route.ComponentProps) {
38− const { teams, slug, canCreate } = loaderData;
46+ const { teams, agentCounts, slug, canCreate } = loaderData;
3947 const [params, setParams] = useSearchParams();
4048 const query = params.get("q") ?? "";
4149 const { mine, others } = splitTeams(filterTeams(teams, query));
8088
8189 {teams.length === 0 ? (
8290 <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.
8593 </EmptyState>
8694 ) : mine.length + others.length === 0 ? (
8795 <p className="rounded-xl border border-dashed border-line px-4 py-8 text-center text-sm text-muted">
92100 </p>
93101 ) : (
94102 <>
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} />}
97105 </>
98106 )}
99107 </div>
100108 );
101109 }
102110
103−function TeamList({ title, teams }: { title: string; teams: Team[] }) {
111+function TeamList({ title, teams, agents }: { title: string; teams: Team[]; agents: Record<string, number> }) {
104112 return (
105113 <section aria-label={title}>
106114 <h2 className="mb-3 text-sm font-medium text-muted">
108116 </h2>
109117 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
110118 {teams.map((team) => (
111− <TeamRow key={team.id} team={team} />
119+ <TeamRow key={team.id} team={team} agents={agents[team.slug]} />
112120 ))}
113121 </ul>
114122 </section>
+2−2
6464 return (
6565 <div className="space-y-8">
6666 <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"} />
6868 <Tile icon={<Sparkles size={12} />} label="Agents" value={agents == null ? "—" : String(agents)} to={`/${slug}/-/agents`} action="Open Agents" />
6969 <Tile icon={<Box size={12} />} label="Projects" value={projects == null ? "—" : String(projects)} to={`/${slug}/-/projects`} action="Open Code" />
7070 </div>
108108 <section className="self-start rounded-xl border border-line bg-surface p-5">
109109 <div className="flex items-center justify-between">
110110 <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">
112112 {role === "owner" ? "Manage" : "See all"}
113113 </Link>
114114 </div>
+14−3
425425 secret alert `admin`, turning security updates on or off `maintain`;
426426 enabling or disabling a workflow needs `maintain`; plans and project memory
427427 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
431431 Settings → Access (`g1t.sh/<owner>/<repo>/settings/access`); invitations
432432 are answered at `g1t.sh/<owner>/<repo>/invitations`.
433433 `GET {repo}/collaborators/{username}/permission` gives a role and what it
441441 `team_reviewers`), and the team may pick who. Guide:
442442 https://docs.g1t.sh/guides/teams/
443443
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+
444454 A CODEOWNERS file (`.g1t/CODEOWNERS`, `.github/CODEOWNERS`, `CODEOWNERS`,
445455 `docs/CODEOWNERS` or `.gitlab/CODEOWNERS`, the first found on the default
446456 branch) asks owners to review pull requests that change their files. With
759769 - [Workspaces and tokens](https://docs.g1t.sh/guides/workspaces/)
760770 - [Access and roles](https://docs.g1t.sh/guides/access-and-roles/)
761771 - [Teams](https://docs.g1t.sh/guides/teams/)
772+- [People and teams](https://docs.g1t.sh/guides/people-and-teams/)
762773 - [CODEOWNERS](https://docs.g1t.sh/guides/codeowners/)
763774 - [Managing a repository](https://docs.g1t.sh/guides/managing-repositories/)
764775 - [Integrations](https://docs.g1t.sh/guides/integrations/)
+1−0
3232 mod names;
3333 mod outcome;
3434 pub mod packages;
35+pub mod people;
3536 pub mod projects;
3637 pub mod repos;
3738 pub mod rules;
+237−0
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+}
+164−0
238238 }
239239 }
240240
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+
241296 /// A team as another names it: its parent, or a child.
242297 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
243298 pub struct TeamRef {
264319 /// Repositories it has a role on itself, not counting inherited ones.
265320 pub repos_count: u32,
266321 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>,
267335 /// The viewer's place in it, if any.
268336 pub viewer_role: Option<TeamRole>,
269337 /// Whether the viewer may change it: an owner of the workspace, or one
468536 pub notify: Option<bool>,
469537 #[serde(default)]
470538 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>,
471553 #[serde(default)]
472554 pub surface: Option<crate::audit::Surface>,
473555 }
474556
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+
475625 /// `delete_team`: its child teams move up to its parent, and the roles it
476626 /// gave go with it. Owners and the team's maintainers. Returns
477627 /// `Outcome<bool>`.
634784 }
635785
636786 #[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]
637801 fn handles_name_a_workspace_and_a_team() {
638802 assert_eq!(parse_handle("@acme/backend"), Some(("acme".into(), "backend".into())));
639803 assert_eq!(parse_handle("@Acme/Backend"), Some(("acme".into(), "backend".into())));
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.