Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

People no longer has an org chart or reporting lines. The Org chart page (/-/org-chart) and its link in People's sidebar and the directory are gone, a person's profile has no Reporting line card, and Edit profile no longer has Reports to. Identity stops reading and writing who someone reports to: set_member_profile takes a title and what someone owns, and the directory and the rosters agents are told no longer carry a manager, so agents are no longer told who reports to whom. The directory, profiles, teams and roles are unchanged. The people-and-teams, teams, workspaces and agents guides, the docs home, llms.txt and the README no longer describe it. The workspace_members.manager_id column and its index stay in the database, unread, so nothing is dropped.

syntaqxcommitted Parent89697d2Browse files
26 files+67−5090/26 viewed
+1−1
3737 | Chat: channels, direct messages and threads, live, with people and agents as members | Live |
3838 | Notifications: mentions, reviews, approvals and alerts, with browser push | Live |
3939 | People and teams: members, roles, nested teams, invites | Live |
40−| A directory of people and agents, profiles, org chart, teams with agent members | Coming |
40+| A directory of people and agents, profiles, teams with agent members | Coming |
4141 | Artifacts: documents written together, live, by people and agents | Live |
4242 | Slides, designs and dashboards as artifacts | Coming |
4343 | Scratchpads: tools, reports and agents anyone can build on a copy of the data, promoted after review | Coming |
+3−4
2323
2424 A workspace can have as many agents as it likes, and an agent costs nothing
2525 while nobody talks to it. Hire one from each template, add each to its
26−team, and a workspace's org chart can read like a real company's:
26+team, and a workspace's teams can read like a real company's:
2727
2828 | Team | Hired from the template | As |
2929 | --- | --- | --- |
312312 An agent's page in Agents is its only profile. The workspace's
313313 [People directory](/guides/people-and-teams/#the-directory) lists people
314314 only; agents appear there on the teams they are on, under the team's
315−people and marked **Agent**, and in the org chart beside the person who
316−leads their team.
315+people and marked **Agent**.
317316
318317 The Agents sidebar groups specialists by the teams they are on, each team
319318 by name: an agent on two teams shows under both, and agents on none show
505504 Every agent, not only g1t, knows the team: each colleague's name, title,
506505 the teams it is on, responsibilities and status. It also knows the people and agents on
507506 its own [teams](/guides/teams/): who leads each, what each person owns,
508−who reports to whom, who is around now and their local time, and who to
507+who is around now and their local time, and who to
509508 ask when a person is needed. It is told this every turn; see
510509 [what agents are told](/guides/people-and-teams/#what-agents-are-told).
511510 When a question belongs to someone else, it makes one of three moves,
+10−33
11 ---
22 title: People and teams
3−description: Find anyone in a workspace, see what they own and who they report to, put agents on teams beside them, and know what agents are told about their teams.
3+description: Find anyone in a workspace, see what they own, put agents on teams beside them, and know what agents are told about their teams.
44 ---
55
66 **People** is where a workspace's people are listed: who they are, the
7−teams they are on, what they own and who they report to. Agents are not
8−in it: each has its page under [Agents](/guides/agents/), and agents join
7+teams they are on and what they own. Agents are not in it: each has its page under [Agents](/guides/agents/), and agents join
98 [teams](/guides/teams/) beside people. Open it from **People** at the foot
109 of the rail. Its sidebar has:
1110
1312 | --- | --- | --- |
1413 | **Everyone** | `g1t.sh/<workspace>/-/people` | The [directory](#the-directory) of the workspace's people. |
1514 | **Teams** | `g1t.sh/<workspace>/-/teams` | The workspace's [teams](/guides/teams/). |
16−| **Org chart** | `g1t.sh/<workspace>/-/org-chart` | Who reports to whom. See [the org chart](#the-org-chart). |
1715
1816 Only members of the workspace can open these pages. Who belongs, and
1917 inviting people, is the workspace's own business: **Members**,
4644 [Agents](/guides/agents/), and a team's page lists the agents on it under
4745 its people (see [agents on a team](/guides/teams/#agents-on-a-team)).
4846
49−**Org chart** opens [the org chart](#the-org-chart). Owners also see
50−**Invite people**, which opens the workspace's
47+Owners see **Invite people**, which opens the workspace's
5148 [Members](#members-invitations-and-permissions) page, where **Invite** is.
5249
5350 ## A person's profile
6360 | Card | What it shows |
6461 | --- | --- |
6562 | **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. |
6763 | **Agents on their teams** | Every agent on the teams they are on. |
6864 | **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. |
6965
8177 | --- | --- | --- |
8278 | **Title** | The person, or an owner | Their job here, such as *Staff Engineer*. Up to 80 characters. |
8379 | **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. |
8580
8681 What you set here is shown in the directory and told to the agents on
8782 their teams (see [what agents are told](#what-agents-are-told)). Each
8883 change is recorded in the workspace's [audit log](/guides/audit-log/) as
8984 `member.profile_edited`.
9085
91−When someone leaves the workspace, anyone who reported to them reports to
92−no one, and any team they led has no lead.
86+When someone leaves the workspace, any team they led has no lead.
9387
9488 ## Agents
9589
9993 memory, routines, spend and activity, and its **Profile** tab, which has
10094 its definition and, under **Teams**, the teams it is on and
10195 [what it is told about them](#what-agents-are-told). Wherever People
102−names an agent, on a team's page, on a person's profile under **Agents
103−on their teams** or in the org chart, the name opens that page.
96+names an agent, on a team's page or on a person's profile under
97+**Agents on their teams**, the name opens that page.
10498
10599 Agents still join teams like anyone: see [teams of any mix](#teams-of-any-mix)
106100 below. The old address of an agent's People profile,
120114 | Add or remove an agent | The team's **People and agents** tab, or **Teams** on the agent's **Profile** tab in Agents. See [agents on a team](/guides/teams/#agents-on-a-team). |
121115 | Set the lead, channel and budget | The team's **Settings**. See [lead, channel and budget](/guides/teams/#lead-channel-and-budget). |
122116 | Storage for a team | Coming. |
123−
124−## The org chart
125117
126−The org chart, `g1t.sh/<workspace>/-/org-chart`, draws the reporting
127−lines as a tree: each person, with the people who report to them below.
128−Beside each person are the agents on the teams they lead, as members of
129−those teams: hover one for its name, title and that it is an agent, and
130−choose it to open its page in Agents. Agents have no place of their own
131−in the tree, since no one reports to an agent and an agent reports to no
132−one.
133−
134−Under **Teams no person leads** are the teams led by an agent or by no
135−one, each with its agents.
136−
137−Until someone has a manager, the page says so. Owners set who each person
138−reports to with **Edit profile** on that person's profile (see
139−[edit a profile](#edit-a-profile)).
140−
141118 ## What agents are told
142119
143120 Every time an agent replies, and at every step of a
150127 1. The team's description, who leads it, its channel and its shared
151128 budget for agents.
152129 2. Each person on it: name and username, title, whether they are the lead
153− or a maintainer, what they own, who they report to, how they show right
154− now, and their local time.
130+ or a maintainer, what they own, how they show right now, and their
131+ local time.
155132 3. Each agent on it, with *you* for itself.
156133 4. **When a person is needed**: who to ask.
157134
202179
203180 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.
204181
205−- Priya Shah (@priya); Engineering Manager, the lead, a maintainer; owns Stripe, Invoices; reports to @dana (online, 14:05 their time)
206−- Leo Park (@leo); Staff Engineer; owns Refunds; reports to @priya (focusing, in Do Not Disturb until 16:00 their time, 22:05 their time)
182+- Priya Shah (@priya); Engineering Manager, the lead, a maintainer; owns Stripe, Invoices (online, 14:05 their time)
183+- Leo Park (@leo); Staff Engineer; owns Refunds (focusing, in Do Not Disturb until 16:00 their time, 22:05 their time)
207184 - @margo: you
208185 - @sam (Sam), an agent: Support Specialist
209186
+3−3
1515 Only members of a workspace, and its own agents, can be in its teams.
1616 Someone who leaves the workspace leaves all of its teams with it.
1717
18−For the workspace's directory, profiles and org chart, see
18+For the workspace's directory and profiles, see
1919 [people and teams](/guides/people-and-teams/).
2020
2121 ## Create a team
182182
183183 Every agent on a visible team is told about it every time it replies and
184184 at every session step: its description, lead, channel and budget, each
185−person with their title, what they own, who they report to, whether they
186−are around and their local time, and the team's other agents. The
185+person with their title, what they own, whether they are around and
186+their local time, and the team's other agents. The
187187 **People and agents** tab shows the team's part of it, exactly as the
188188 agents get it, under **What the agents on** *team* **know about it**.
189189
+1−2
571571 | [**Packages**](/guides/packages/) | `/-/packages` | Everyone | What the workspace publishes. A visitor opens it from **Packages** on the workspace's page. |
572572 | [**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>`. |
573573 | [**People**](/guides/people-and-teams/) | `/-/people` | Members | The workspace's people, with their titles, teams and what they own. Each has a profile at `/-/people/<username>`. Agents are not in it; an agent's page is `/-/agents/<handle>`. |
574−| [**Org chart**](/guides/people-and-teams/#the-org-chart) | `/-/org-chart` | Members | Who reports to whom, with the agents on the teams each person leads beside them. |
575574 | **Members and invites** | `/-/members` | Members | Who belongs. Owners invite, change roles and remove people here. |
576575 | **Insights** | `/-/insights` | Members | Coming soon: how the whole workspace delivers. |
577576 | **Settings** | `/-/settings` | Owners | How the workspace is set up and connected (below). |
637636 | **Code** | Projects, pull requests and checks. Not shown to a member without [Code access](/guides/agent-access/). |
638637 | **Agents** | The workspace's agents and their sessions, and how they are set up. See [agents](/guides/agents/). |
639638 | **Artifacts** | Documents, decks and pages. See [artifacts](/guides/artifacts/). |
640−| **People** | Everyone in the workspace (its people; agents are under Agents); its [teams](/guides/teams/), which agents can be on; and the org chart. See [people and teams](/guides/people-and-teams/). |
639+| **People** | Everyone in the workspace (its people; agents are under Agents); and its [teams](/guides/teams/), which agents can be on. See [people and teams](/guides/people-and-teams/). |
641640 | **Workspace** | The workspace's settings: who has access, money, compute, and how each part of g1t is set up. See [Workspace mode](#workspace-mode). |
642641
643642 The app you are in sits on a filled square. Your account is the
+2−2
6666 | [Home](/guides/home/) | The front page: where you're needed now, what happened since you were last here, what is running, and where to start. | <Status is="preview" /> |
6767 | [Chat](/guides/chat/) | Channels, direct messages and threads, live. People and agents are members alike. No seats and no history limit. | <Status is="live" /> |
6868 | [Notifications](/guides/notifications/) | Everything waiting on you: mentions, reviews, approvals and alerts, with browser push. | <Status is="live" /> |
69−| [People and teams](/guides/people-and-teams/) | Everyone in the workspace in one search, with profiles, reporting lines, an org chart and teams that include agents beside people. Members, roles, nested teams and invites. | <Status is="live" /> |
69+| [People and teams](/guides/people-and-teams/) | Everyone in the workspace in one search, with profiles and teams that include agents beside people. Members, roles, nested teams and invites. | <Status is="live" /> |
7070 | [Artifacts](/guides/artifacts/) | Documents written together, live, by people and agents. Private until you share them. | <Status is="live" /> |
7171 | More kinds of artifact | Slides, designs and dashboards that keep the query behind every number. | <Status is="coming" /> |
7272 | Scratchpads | Tools, reports and agents anyone can build on a copy of the data, used in production only after review. | <Status is="coming" /> |
208208 />
209209 <LinkCard
210210 title="People and teams"
211− description="Find anyone, person or agent, see what they own and who they report to, and read what agents are told about their teams."
211+ description="Find anyone, person or agent, see what they own, and read what agents are told about their teams."
212212 href="/guides/people-and-teams/"
213213 />
214214 <LinkCard
+2−7
13561356
13571357 /**
13581358 * People's sidebar: everyone in the workspace (its people; agents are
1359− * under Agents); its teams, which agents can be on; and the org chart. Who
1360− * belongs, and inviting people, is the workspace's: Workspace mode's
1361− * Access group.
1359+ * under Agents) and its teams, which agents can be on. Who belongs, and
1360+ * inviting people, is the workspace's: Workspace mode's Access group.
13621361 */
13631362 function PeopleSidebar({ slug }: { slug: string }) {
13641363 const { pathname } = useLocation();
13761375 <SidebarLink to={`/${slug}/-/teams`} icon={<UsersRound size={15} />} current={at("teams")}>
13771376 Teams
13781377 </SidebarLink>
1379− <SidebarLink to={`/${slug}/-/org-chart`} icon={<Network size={15} />} current={at("org-chart")}>
1380− Org chart
1381− </SidebarLink>
13821378 </NavRows>
13831379 </nav>
13841380 </div>
14771473 secrets: "Secrets and variables",
14781474 settings: "Settings",
14791475 people: "People",
1480− "org-chart": "Org chart",
14811476 members: "Members",
14821477 permissions: "Permissions",
14831478 projects: "Projects",
+3−30
1313 budgetFromText,
1414 codeAccessWords,
1515 directoryEntries,
16− managerChoices,
17− orgChart,
1816 ownsFromText,
1917 teamBlock,
2018 teamsOfAgent,
3129 timezone: null,
3230 role: "member",
3331 title: null,
34− manager: null,
3532 owns: [],
3633 joined_at: "2026-10-01T00:00:00Z",
3734 ...extra,
7067 const directory: PeopleDirectory = {
7168 people: [
7269 person("chase", { name: "Chase Pierce", title: "Founder", role: "owner" }),
73− person("sofia", { name: "Sofia Reyes", title: "Head of Sales", manager: "chase", owns: ["the forecast"] }),
74− person("jordan", { name: "Jordan Lee", title: "Account Executive", manager: "sofia", owns: ["Halcyon", "Bluebird"] }),
75− person("kai", { name: "Kai Nakamura", title: "Product Designer", manager: "chase" }),
70+ person("sofia", { name: "Sofia Reyes", title: "Head of Sales", owns: ["the forecast"] }),
71+ person("jordan", { name: "Jordan Lee", title: "Account Executive", owns: ["Halcyon", "Bluebird"] }),
72+ person("kai", { name: "Kai Nakamura", title: "Product Designer" }),
7673 ],
7774 teams: [
7875 team("sales", "Sales", {
136133
137134 test("an agent's profile is its page in Agents, where People links to it", () => {
138135 assert.equal(agentPath("acme", "margo"), "/acme/-/agents/margo");
139−});
140−
141−test("the org chart follows reporting lines, with each team's agents beside its lead", () => {
142− const chart = orgChart(directory, agents);
143− assert.deepEqual(chart.roots.map((n) => n.person.username), ["chase"]);
144− const chase = chart.roots[0]!;
145− assert.deepEqual(chase.reports.map((n) => n.person.username), ["sofia", "kai"]);
146− const sofia = chase.reports[0]!;
147− assert.deepEqual(sofia.agents.map((a) => a.handle), ["david"]);
148− assert.deepEqual(sofia.reports.map((n) => n.person.username), ["jordan"]);
149− // A team an agent leads sits apart, with its agents.
150− assert.deepEqual(chart.unled.map((u) => [u.team.slug, u.agents.map((a) => a.handle)]), [["night-shift", ["atlas", "sentinel", "david"]]]);
151−});
152−
153−test("no one goes missing from the org chart, even in a loop", () => {
154− const looped: PeopleDirectory = { ...directory, people: [person("a", { manager: "b" }), person("b", { manager: "a" })] };
155− const chart = orgChart(looped, []);
156− const all = (nodes: typeof chart.roots): string[] => nodes.flatMap((n) => [n.person.username, ...all(n.reports)]);
157− assert.deepEqual(all(chart.roots).sort(), ["a", "b"]);
158−});
159−
160−test("someone can report to anyone but themselves and the people under them", () => {
161− assert.deepEqual(managerChoices(directory.people, "sofia").map((p) => p.username), ["chase", "kai"]);
162− assert.deepEqual(managerChoices(directory.people, "jordan").map((p) => p.username), ["chase", "sofia", "kai"]);
163136 });
164137
165138 test("forms read plainly", () => {
+1−63
11 /**
22 * People on the site: the directory (people only; agents are on their
3− * teams and under Agents), profiles, teams of any mix, and the org chart.
3+ * teams and under Agents), profiles, and teams of any mix.
44 * Types only from the contracts, so this file runs under `node --test`.
55 */
66 import type { DirectoryPerson, DirectoryTeam, PeopleDirectory, TeamLead, WorkspaceAgent } from "@g1t/contracts";
119119 if (matches(query, fields)) out.push({ person, teams });
120120 }
121121 return out;
122−}
123−
124−/** One person in the org chart, with who reports to them and the agents of the teams they lead. */
125−export type OrgNode = { person: DirectoryPerson; agents: PeopleAgent[]; leads: DirectoryTeam[]; reports: OrgNode[] };
126−
127−export type OrgChart = {
128− /** Everyone at the top: no manager, or one who is no longer here. */
129− roots: OrgNode[];
130− /** Teams no person leads (an agent does, or no one), with their agents. */
131− unled: { team: DirectoryTeam; agents: PeopleAgent[] }[];
132−};
133−
134−/**
135− * Reporting lines, with each team's agents beside the person who leads it.
136− * A loop (which identity refuses) would leave its people out of `roots`;
137− * they are put back at the top, so no one is ever missing.
138− */
139−export function orgChart(directory: PeopleDirectory, agents: readonly PeopleAgent[]): OrgChart {
140− const known = new Set(directory.people.map((person) => person.username));
141− const byManager = new Map<string, DirectoryPerson[]>();
142− for (const person of directory.people) {
143− if (person.manager && known.has(person.manager) && person.manager !== person.username) {
144− byManager.set(person.manager, [...(byManager.get(person.manager) ?? []), person]);
145− }
146− }
147− const placed = new Set<string>();
148− const node = (person: DirectoryPerson): OrgNode => {
149− placed.add(person.username);
150− const led = directory.teams.filter((team) => leads(team.lead, { username: person.username }));
151− const theirAgents: PeopleAgent[] = [];
152− for (const team of led) for (const agent of agentsOn(team, agents)) if (!theirAgents.includes(agent)) theirAgents.push(agent);
153− const reports = (byManager.get(person.username) ?? []).filter((report) => !placed.has(report.username)).map(node);
154− return { person, agents: theirAgents, leads: led, reports };
155− };
156− const roots = directory.people.filter((person) => !person.manager || !known.has(person.manager)).map(node);
157− for (const person of directory.people) if (!placed.has(person.username)) roots.push(node(person));
158− const unled = directory.teams
159− .filter((team) => !team.lead || team.lead.kind === "agent")
160− .map((team) => ({ team, agents: agentsOn(team, agents) }))
161− .filter((entry) => entry.agents.length > 0);
162− return { roots, unled };
163−}
164−
165−/** Everyone who reports to `username`, directly. */
166−export function reportsOf(people: readonly DirectoryPerson[], username: string): DirectoryPerson[] {
167− return people.filter((person) => person.manager === username && person.username !== username);
168−}
169−
170−/** Who `username` could report to: anyone but themselves and the people under them. */
171−export function managerChoices(people: readonly DirectoryPerson[], username: string): DirectoryPerson[] {
172− const below = new Set<string>([username]);
173− let grew = true;
174− while (grew) {
175− grew = false;
176− for (const person of people) {
177− if (person.manager && below.has(person.manager) && !below.has(person.username)) {
178− below.add(person.username);
179− grew = true;
180− }
181− }
182− }
183− return people.filter((person) => !below.has(person.username));
184122 }
185123
186124 /** What someone owns, from a form's text: one per line or comma. */
+0−1
106106 assert.equal(modeOf("/acme/-/people/ana", "acme"), "people");
107107 // An agent's old People address (it redirects to Agents) is still read as People's until it does.
108108 assert.equal(modeOf("/acme/-/people/agents/margo", "acme"), "people");
109− assert.equal(modeOf("/acme/-/org-chart", "acme"), "people");
110109 // Members, invitations and permissions are the workspace's settings.
111110 assert.equal(modeOf("/acme/-/members", "acme"), "workspace");
112111 assert.equal(modeOf("/acme/-/members/invitations", "acme"), "workspace");
+0−1
317317 members: "workspace",
318318 permissions: "workspace",
319319 teams: "people",
320− "org-chart": "people",
321320 workspace: "workspace",
322321 spend: "workspace",
323322 usage: "workspace",
+1−2
100100 index("routes/workspace/overview.tsx"),
101101 // Its pages, each in the sidebar: Overview (above), Projects, People, Insights; Teams and Packages below.
102102 route("-/projects", "routes/workspace/projects.tsx"),
103− // People mode: the directory of people, each one's profile, and the org chart. Agents
103+ // People mode: the directory of people and each one's profile. Agents
104104 // are not in it; an agent's old People address redirects to its page in Agents.
105105 route("-/people", "routes/workspace/people.tsx"),
106106 route("-/people/agents/:handle", "routes/workspace/people/agent.tsx"),
107107 route("-/people/:username", "routes/workspace/people/person.tsx"),
108− route("-/org-chart", "routes/workspace/org-chart.tsx"),
109108 // Workspace mode's Access: who is a member and their roles, the invitations sent, and who may do what.
110109 route("-/members", "routes/workspace/members.tsx"),
111110 route("-/members/invitations", "routes/workspace/member-invitations.tsx"),
+0−4
6767 title: "Permissions",
6868 about: "Who may do what in the workspace, part by part: repositories, teams, chat, artifacts, agents, deployments and packages. Owners only.",
6969 },
70− "org-chart": {
71− title: "Org chart",
72− about: "Who reports to whom, with each team's agents beside the person who leads it.",
73− },
7470 projects: {
7571 title: "Projects",
7672 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.",
+0−136
1−import { Info } from "lucide-react";
2−import { Link, data } from "react-router";
3−
4−import type { Route } from "./+types/org-chart";
5−import { AgentPersonFace, PersonFace, TeamKindMark } from "../../components/people";
6−import { Card } from "../../components/ui/card";
7−import { Hint } from "../../components/ui/hint";
8−import { page } from "../../lib/meta";
9−import { type OrgNode, agentPath, orgChart, peopleAgent, personName, personPath } from "../../lib/people";
10−import { teamPath } from "../../lib/teams";
11−import { identity, workspaceAgents } from "../../lib/services.server";
12−import { getViewer, roleIn, unwrap } from "../../lib/session.server";
13−
14−export function meta({ params, ...args }: Route.MetaArgs) {
15− return page(args, { title: `Org chart · ${params.owner} · g1t` });
16−}
17−
18−export async function loader({ params, context }: Route.LoaderArgs) {
19− const viewer = getViewer(context);
20− if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
21− const [directory, agents] = await Promise.all([
22− identity.peopleDirectory(viewer, params.owner).then(unwrap),
23− workspaceAgents.list(params.owner, viewer!).catch(() => null),
24− ]);
25− return { slug: params.owner.toLowerCase(), directory, agents: agents?.ok ? agents.value.map(peopleAgent) : [] };
26−}
27−
28−export default function OrgChartPage({ loaderData }: Route.ComponentProps) {
29− const { slug, directory, agents } = loaderData;
30− const chart = orgChart(directory, agents);
31− const lines = directory.people.some((person) => person.manager);
32−
33− return (
34− <div className="space-y-6">
35− {!lines && (
36− <Card asChild radius="lg" className="flex items-start gap-2 px-3 py-2.5 text-sm text-muted">
37− <p>
38− <Info size={15} className="mt-0.5 shrink-0 text-faint" />
39− {directory.can_manage
40− ? "No one reports to anyone yet. Set who each person reports to under Edit profile on their profile."
41− : "No reporting lines yet. An owner sets who each person reports to on their profile."}
42− </p>
43− </Card>
44− )}
45− <Card asChild className="overflow-x-auto p-4 sm:p-5">
46− <section aria-label="Reporting lines">
47− <ul className="space-y-1">
48− {chart.roots.map((root) => (
49− <Branch key={root.person.username} slug={slug} node={root} />
50− ))}
51− </ul>
52− <p className="mt-4 text-xs text-faint">The agents on a team appear beside the person who leads it, as its members; each opens the agent's page in Agents.</p>
53− </section>
54− </Card>
55−
56− {chart.unled.length > 0 && (
57− <section aria-labelledby="unled" className="space-y-3">
58− <h2 id="unled" className="text-sm font-medium text-muted">
59− Teams no person leads
60− </h2>
61− <ul className="grid gap-3 sm:grid-cols-2">
62− {chart.unled.map(({ team, agents: teamAgents }) => (
63− <Card asChild key={team.slug} className="p-4">
64− <li>
65− <div className="flex items-center gap-2">
66− <TeamKindMark people={team.people.length} agents={teamAgents.length} />
67− <Link to={teamPath(slug, team.slug)} className="font-medium hover:text-accent">
68− {team.name}
69− </Link>
70− {team.lead?.kind === "agent" && (
71− <span className="text-xs text-faint">led by {teamAgents.find((a) => a.id === (team.lead as { agent_id: string }).agent_id)?.display_name ?? "an agent"}</span>
72− )}
73− </div>
74− <div className="mt-3 flex flex-wrap gap-2">
75− {teamAgents.map((agent) => (
76− <Link key={agent.id} to={agentPath(slug, agent.handle)} className="inline-flex items-center gap-1.5 rounded-full border border-line py-0.5 pr-2.5 pl-0.5 text-xs hover:border-line-strong">
77− <AgentPersonFace agent={agent} size={20} ring="var(--color-surface)" />
78− {agent.display_name}
79− </Link>
80− ))}
81− </div>
82− </li>
83− </Card>
84− ))}
85− </ul>
86− </section>
87− )}
88− </div>
89− );
90−}
91−
92−/** One person, the agents of the teams they lead, and everyone who reports to them below. */
93−function Branch({ slug, node }: { slug: string; node: OrgNode }) {
94− const { person } = node;
95− return (
96− <li>
97− <div className="flex flex-wrap items-center gap-2 py-1">
98− <Link
99− to={personPath(slug, person.username)}
100− prefetch="intent"
101− className="inline-flex min-w-0 items-center gap-2.5 rounded-lg border border-line bg-bg py-1.5 pr-3 pl-1.5 transition-colors hover:border-accent/60"
102− >
103− <PersonFace person={person} size={28} />
104− <span className="grid min-w-0 text-left">
105− <span className="truncate text-sm font-medium">{personName(person)}</span>
106− <span className="truncate text-xs text-muted">{person.title ?? `@${person.username}`}</span>
107− </span>
108− </Link>
109− {node.agents.length > 0 && (
110− <span className="inline-flex items-center gap-2">
111− <span className="flex">
112− {node.agents.slice(0, 6).map((agent, index) => (
113− <Hint key={agent.id} label={`${agent.display_name} · ${agent.title || agent.role} · agent`}>
114− <Link to={agentPath(slug, agent.handle)} className={index ? "-ml-1.5" : undefined} aria-label={agent.display_name}>
115− <AgentPersonFace agent={agent} size={22} ring="var(--color-surface)" />
116− </Link>
117− </Hint>
118− ))}
119− </span>
120− <span className="text-xs text-faint">
121− {node.agents.length > 6 ? `+${node.agents.length - 6} · ` : ""}
122− {node.leads.map((team) => team.name).join(", ")}
123− </span>
124− </span>
125− )}
126− </div>
127− {node.reports.length > 0 && (
128− <ul className="ml-[17px] space-y-1 border-l border-dashed border-line-strong pl-5 sm:pl-7">
129− {node.reports.map((report) => (
130− <Branch key={report.person.username} slug={slug} node={report} />
131− ))}
132− </ul>
133− )}
134− </li>
135− );
136−}
+1−5
1−import { Bot, Network, Search, UserPlus } from "lucide-react";
1+import { Bot, Search, UserPlus } from "lucide-react";
22 import { Link, data, useSearchParams, type ShouldRevalidateFunctionArgs } from "react-router";
33
44 import type { Route } from "./+types/people";
7070 <span role="status" className="mr-1 text-sm text-muted tabular-nums">
7171 {query ? `${entries.length} of ${total}` : total} {total === 1 && !query ? "person" : "people"}
7272 </span>
73− <ButtonLink to={`/${slug}/-/org-chart`} variant="outline">
74− <Network size={15} />
75− Org chart
76− </ButtonLink>
7773 {directory.can_manage && (
7874 <ButtonLink to={`/${slug}/-/members`}>
7975 <UserPlus size={15} />
+6−67
1−import { Bot, ChevronRight, KeyRound, MapPin, MessageSquare, Network, Pencil, UsersRound, Wallet } from "lucide-react";
1+import { Bot, ChevronRight, KeyRound, MapPin, MessageSquare, Pencil, UsersRound, Wallet } from "lucide-react";
22 import { type ReactNode, useEffect, useState } from "react";
33 import { Form, Link, data, redirect, useNavigation } from "react-router";
44
55 import type { DirectoryPerson } from "@g1t/contracts";
66
77 import type { Route } from "./+types/person";
8−import { AgentLink, AgentPersonFace, Coming, LocalTime, PersonFace, PersonLink, TeamKindMark } from "../../../components/people";
8+import { AgentLink, AgentPersonFace, Coming, LocalTime, PersonFace, TeamKindMark } from "../../../components/people";
99 import { PresenceSummary } from "../../../components/presence";
1010 import { ErrorText, Field, Input, SubmitButton, Textarea } from "../../../components/ui";
1111 import { Badge } from "../../../components/ui/badge";
1212 import { Button } from "../../../components/ui/button";
1313 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../../components/ui/dialog";
14−import { SelectField } from "../../../components/ui/select";
1514 import { channelPath } from "../../../lib/chat";
1615 import { page } from "../../../lib/meta";
1716 import {
1817 agentsOn,
1918 codeAccessWords,
2019 leads,
21− managerChoices,
2220 ownsFromText,
2321 peopleAgent,
2422 personName,
25− reportsOf,
2623 teamsOfPerson,
2724 } from "../../../lib/people";
2825 import { teamPath } from "../../../lib/teams";
3532 return page(args, { title: `${name} · People · ${params.owner} · g1t` });
3633 }
3734
38−/** The few fields of everyone else a profile shows. */
39−function slim(person: DirectoryPerson) {
40− const { user_id, username, display_username, name, avatar, title, manager } = person;
41− return { user_id, username, display_username, name, avatar, title, manager };
42−}
43−
4435 export async function loader({ params, context }: Route.LoaderArgs) {
4536 const viewer = getViewer(context);
4637 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
6051 return {
6152 slug: params.owner.toLowerCase(),
6253 person,
63− people: directory.people.map(slim),
6454 teams,
6555 agents: all,
6656 base: directory.base_permission,
8474 throw redirect(channelPath(params.owner.toLowerCase(), opened.value));
8575 }
8676 if (intent === "profile") {
87− const changes: { title?: string; owns?: string[]; manager?: string } = {
77+ const changes = {
8878 title: String(form.get("title") ?? ""),
8979 owns: ownsFromText(String(form.get("owns") ?? "")),
9080 };
91− if (form.has("manager")) {
92− const manager = String(form.get("manager") ?? "");
93− changes.manager = manager === "none" ? "" : manager;
94− }
9581 const saved = await identity.setMemberProfile(user, params.owner, params.username, changes);
9682 return saved.ok ? { intent, saved: true as const, error: null } : { intent, error: saved.error.message };
9783 }
9985 }
10086
10187 export default function PersonProfile({ loaderData, actionData }: Route.ComponentProps) {
102− const { slug, person, people, teams, agents, base, canManage, self, spend } = loaderData;
103− const manager = person.manager ? people.find((p) => p.username === person.manager) : null;
104− const reports = reportsOf(people as DirectoryPerson[], person.username);
88+ const { slug, person, teams, agents, base, canManage, self, spend } = loaderData;
10589 const teamAgents = teams.flatMap((team) => agentsOn(team, agents).map((agent) => ({ agent, team })));
10690 const seen = new Set<string>();
10791 const uniqueAgents = teamAgents.filter(({ agent }) => (seen.has(agent.id) ? false : (seen.add(agent.id), true)));
154138 </SubmitButton>
155139 </Form>
156140 )}
157− {mayEdit && <EditProfile person={person} people={people as DirectoryPerson[]} canManage={canManage} actionData={actionData} />}
141+ {mayEdit && <EditProfile person={person} actionData={actionData} />}
158142 </div>
159143 </header>
160144 {actionData?.intent === "message" && <ErrorText>{actionData.error}</ErrorText>}
198182 )}
199183 </Card>
200184
201− <Card icon={<Network size={14} />} title="Reporting line">
202− <dl className="space-y-3 text-sm">
203− <div>
204− <dt className="text-xs text-faint">Reports to</dt>
205− <dd className="mt-1">{manager ? <PersonRow slug={slug} person={manager} /> : <Quiet>No one{canManage ? ". Set it under Edit profile." : "."}</Quiet>}</dd>
206− </div>
207− <div>
208− <dt className="text-xs text-faint">
209− Reports <span className="tabular-nums">{reports.length}</span>
210− </dt>
211− <dd className="mt-1 space-y-1.5">
212− {reports.length ? reports.map((report) => <PersonRow key={report.username} slug={slug} person={report} />) : <Quiet>No one reports to them.</Quiet>}
213− </dd>
214− </div>
215− </dl>
216− </Card>
217−
218185 <Card icon={<Bot size={14} />} title="Agents on their teams">
219186 {uniqueAgents.length ? (
220187 <ul className="space-y-2">
285252 return <p className="text-sm text-faint">{children}</p>;
286253 }
287254
288−function PersonRow({ slug, person }: { slug: string; person: Pick<DirectoryPerson, "user_id" | "username" | "display_username" | "name" | "avatar" | "title"> }) {
289− return (
290− <span className="flex min-w-0 items-center gap-2.5">
291− <PersonFace person={person} size={24} ring="var(--color-surface)" />
292− <span className="min-w-0 truncate">
293− <PersonLink workspace={slug} person={person} className="font-medium" />
294− {person.title && <span className="text-muted"> · {person.title}</span>}
295− </span>
296− </span>
297− );
298−}
299−
300−/** Their title and what they own (theirs, or an owner's to change), and who they report to (an owner's). */
255+/** Their title and what they own: theirs, or an owner's, to change. */
301256 function EditProfile({
302257 person,
303− people,
304− canManage,
305258 actionData,
306259 }: {
307260 person: DirectoryPerson;
308− people: DirectoryPerson[];
309− canManage: boolean;
310261 actionData: { intent: string; error: string | null; saved?: true } | undefined;
311262 }) {
312263 const [open, setOpen] = useState(false);
314265 useEffect(() => {
315266 if (saved) setOpen(false);
316267 }, [saved, actionData]);
317− const choices = managerChoices(people, person.username);
318268 return (
319269 <>
320270 <Button variant="outline" onClick={() => setOpen(true)}>
335285 <Field label="What they own" hint="One per line: a product, a process, an account. Agents ask them about these.">
336286 <Textarea name="owns" defaultValue={person.owns.join("\n")} rows={3} placeholder={"storefront\nthe release process"} />
337287 </Field>
338− {canManage && (
339− <label className="block">
340− <span className="mb-1.5 block text-sm font-medium text-muted">Reports to</span>
341− <SelectField
342− name="manager"
343− defaultValue={person.manager ?? "none"}
344− className="w-full"
345− options={[{ value: "none", label: "No one" }, ...choices.map((p) => ({ value: p.username, label: `${personName(p)}${p.title ? ` · ${p.title}` : ""}` }))]}
346− />
347− </label>
348− )}
349288 {actionData?.intent === "profile" && <ErrorText>{actionData.error}</ErrorText>}
350289 <DialogFooter>
351290 <Button variant="outline" type="button" onClick={() => setOpen(false)}>
+5−6
450450 as a member that owners and the team's maintainers add and remove; nothing
451451 on the agent names a team. Every reply and session step, an agent is
452452 told each visible team it is on: who leads it, each person's title, what
453−they own, who they report to, whether they are around and their local
454−time, and who to ask when a person is needed. Ask the person who owns
455−something before guessing. The directory of people is
456−`g1t.sh/<owner>/-/people` (people only; a team's page lists the agents on
457−it), profiles `-/people/<username>`, the org chart `-/org-chart`; an
458−agent's page is `-/agents/<handle>`. Guide:
453+they own, whether they are around and their local time, and who to ask
454+when a person is needed. Ask the person who owns something before
455+guessing. The directory of people is `g1t.sh/<owner>/-/people` (people
456+only; a team's page lists the agents on it), profiles
457+`-/people/<username>`; an agent's page is `-/agents/<handle>`. Guide:
459458 https://docs.g1t.sh/guides/people-and-teams/
460459
461460 Anyone creates an agent by describing it at `g1t.sh/<owner>/-/agents/new`:
+12−55
11 //! People: a workspace's directory of everyone in it, people and agents,
2−//! and what each person's place is: a title, who they report to, and what
3−//! they own. Kept by identity beside membership; agents come from the
2+//! and what each person's place is: a title and what they own. Kept by
3+//! identity beside membership; agents come from the
44 //! agents service, which identity names only by id.
55 //!
66 //! **Profiles.** A member's title and what they own are theirs to write,
7−//! and the workspace's owners can write anyone's. Who someone reports to
8−//! (their manager) is set by owners: always another member, never
9−//! themselves, and never a loop.
7+//! and the workspace's owners can write anyone's.
108 //!
119 //! **Agents on teams.** A team can have agents as well as people: mixed,
1210 //! people only or agents only. An agent is on a team when it was added to
2725 pub const MAX_OWNS: usize = 8;
2826 /// The longest of them.
2927 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;
3228
33−/// One member, as the directory, their profile and the org chart show them.
29+/// One member, as the directory and their profile show them.
3430 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
3531 pub struct DirectoryPerson {
3632 pub user_id: String,
5046 pub role: Role,
5147 /// Their title in this workspace.
5248 pub title: Option<String>,
53− /// Who they report to, by username.
54− pub manager: Option<String>,
5549 /// What they own: a few short phrases.
5650 pub owns: Vec<String>,
5751 /// When they joined the workspace (RFC 3339).
6559 pub role: TeamRole,
6660 }
6761
68−/// A team as the directory and the org chart need it: who is on it, by
69−/// username and agent id, and who leads it.
62+/// A team as the directory needs it: who is on it, by username and agent
63+/// id, and who leads it.
7064 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
7165 pub struct DirectoryTeam {
7266 pub slug: String,
10296 /// What members get on every repository: `none`, `read`, `write` or `admin`.
10397 pub base_permission: String,
10498 /// Whether the viewer owns the workspace: they can set anyone's
105− /// title, manager and what they own.
99+ /// title and what they own.
106100 pub can_manage: bool,
107101 }
108102
109−/// `set_member_profile`: what is given changes; the rest stays. `manager`
110−/// set to an empty string clears it. Returns `Outcome<DirectoryPerson>`.
103+/// `set_member_profile`: what is given changes; the rest stays. Returns
104+/// `Outcome<DirectoryPerson>`.
111105 #[derive(Debug, Default, Serialize, Deserialize)]
112106 pub struct SetMemberProfileArgs {
113107 pub actor: User,
115109 pub username: String,
116110 #[serde(default)]
117111 pub title: Option<String>,
118− /// A member's username, or an empty string for none. Owners only.
119112 #[serde(default)]
120− pub manager: Option<String>,
121− #[serde(default)]
122113 pub owns: Option<Vec<String>>,
123114 #[serde(default)]
124115 pub surface: Option<crate::audit::Surface>,
151142 out
152143 }
153144
154−/// What may change on someone's profile: their title and what they own
155−/// by themselves or an owner; their manager by an owner.
145+/// What may change on someone's profile: their title and what they own,
146+/// by themselves or an owner.
156147 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
157148 pub enum ProfileField {
158149 Title,
159150 Owns,
160− Manager,
161151 }
162152
163153 /// Whether `actor` may change `field` on someone's profile.
164154 pub fn may_edit_profile(field: ProfileField, is_self: bool, owner: bool) -> bool {
165155 match field {
166156 ProfileField::Title | ProfileField::Owns => is_self || owner,
167− ProfileField::Manager => owner,
168157 }
169158 }
170159
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−
189160 #[cfg(test)]
190161 mod tests {
191162 use super::*;
192− use std::collections::HashMap;
193163
194164 #[test]
195165 fn titles_are_tidied() {
216186 }
217187
218188 #[test]
219− fn people_write_their_own_and_owners_set_managers() {
189+ fn people_write_their_own_and_owners_anyones() {
220190 assert!(may_edit_profile(ProfileField::Title, true, false));
221191 assert!(may_edit_profile(ProfileField::Owns, false, true));
222192 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));
236193 }
237194 }
+0−2
648648 pub title: Option<String>,
649649 pub timezone: Option<String>,
650650 pub owns: Vec<String>,
651− /// Who they report to, by username.
652− pub manager: Option<String>,
653651 pub maintainer: bool,
654652 }
655653
+7−11
11 /**
22 * People: a workspace's directory of everyone in it, people and agents,
3− * and each member's place in it: a title, who they report to, and what
4− * they own. Kept by identity beside membership; mirrors
5− * `crates/contracts/src/people.rs`. Agents come from the agents service,
3+ * and each member's place in it: a title and what they own. Kept by
4+ * identity beside membership; mirrors `crates/contracts/src/people.rs`. Agents come from the agents service,
65 * which teams name by id.
76 */
87 import type { User } from "./identity";
1514 export const MAX_OWNS = 8;
1615 export const MAX_OWNS_LENGTH = 60;
1716
18−/** One member, as the directory, their profile and the org chart show them. */
17+/** One member, as the directory and their profile show them. */
1918 export type DirectoryPerson = {
2019 user_id: string;
2120 username: string;
3029 role: "owner" | "member";
3130 /** Their title in this workspace. */
3231 title: string | null;
33− /** Who they report to, by username. */
34− manager: string | null;
3532 /** What they own: a few short phrases. */
3633 owns: string[];
3734 joined_at: string;
3936
4037 export type TeamPersonRef = { username: string; role: TeamRole };
4138
42−/** A team as the directory and the org chart need it. */
39+/** A team as the directory needs it. */
4340 export type DirectoryTeam = {
4441 slug: string;
4542 name: string;
6158 teams: DirectoryTeam[];
6259 /** What members get on every repository. */
6360 base_permission: "none" | "read" | "write" | "admin";
64− /** Whether the viewer owns the workspace: they set anyone's title, manager and what they own. */
61+ /** Whether the viewer owns the workspace: they set anyone's title and what they own. */
6562 can_manage: boolean;
6663 };
6764
68−/** What changes on a member's profile; the rest stays. `manager: ""` clears it (owners only). */
65+/** What changes on a member's profile; the rest stays. */
6966 export type MemberProfileChanges = {
7067 title?: string;
71− manager?: string;
7268 owns?: string[];
7369 };
7470
7571 export interface PeopleClient {
7672 /** Everyone in a workspace and its teams, as the viewer may see them. Members only. */
7773 peopleDirectory(viewer: User | null, workspace: string): Promise<Result<PeopleDirectory>>;
78− /** A member's title and what they own (themselves or an owner), and their manager (owners). */
74+ /** A member's title and what they own: themselves, or an owner. */
7975 setMemberProfile(actor: User, workspace: string, username: string, changes: MemberProfileChanges): Promise<Result<DirectoryPerson>>;
8076 }
8177
+0−2
9595 /** An IANA time zone, for their local time. */
9696 timezone: string | null;
9797 owns: string[];
98− /** Who they report to, by username. */
99− manager: string | null;
10098 maintainer: boolean;
10199 };
102100
+4−5
1414 title: null,
1515 timezone: null,
1616 owns: [],
17− manager: null,
1817 maintainer: false,
1918 ...extra,
2019 });
3736 channel: { id: "chn_1", name: "sales" },
3837 budget_micros: 150_000_000,
3938 people: [
40− person("sofia", { name: "Sofia Reyes", title: "Head of Sales", owns: ["the forecast"], manager: "chase", maintainer: true, timezone: "America/Chicago" }),
41− person("jordan", { name: "Jordan Lee", title: "Account Executive", owns: ["Halcyon", "Bluebird"], manager: "sofia" }),
39+ person("sofia", { name: "Sofia Reyes", title: "Head of Sales", owns: ["the forecast"], maintainer: true, timezone: "America/Chicago" }),
40+ person("jordan", { name: "Jordan Lee", title: "Account Executive", owns: ["Halcyon", "Bluebird"] }),
4241 ],
4342 agent_ids: ["agt_david"],
4443 };
6665 assert.match(text, /Led by Sofia Reyes \(@sofia\)\./);
6766 assert.match(text, /Its channel is #sales\./);
6867 assert.match(text, /share a budget of \$150\.00 a month/);
69− assert.match(text, /Sofia Reyes \(@sofia\); Head of Sales, the lead, a maintainer; owns the forecast; reports to @chase \(focusing in Do Not Disturb until 23:12 their time; it's 20:12 for them\)/);
70− assert.match(text, /Jordan Lee \(@jordan\); Account Executive; owns Halcyon, Bluebird; reports to @sofia \(online\)/);
68+ assert.match(text, /Sofia Reyes \(@sofia\); Head of Sales, the lead, a maintainer; owns the forecast \(focusing in Do Not Disturb until 23:12 their time; it's 20:12 for them\)/);
69+ assert.match(text, /Jordan Lee \(@jordan\); Account Executive; owns Halcyon, Bluebird \(online\)/);
7170 assert.match(text, /- @david: you/);
7271 // Sofia is focusing, so Jordan is the one to page.
7372 assert.match(text, /When a person is needed: Jordan Lee \(@jordan\), who is online now\./);
+1−3
22 * Agents know their teammates (docs.g1t.sh/guides/people-and-teams/,
33 * "What agents are told"): every turn, an agent is told the visible teams
44 * it is on, from the team pages: who leads each, who is on it and what
5− * they own, who reports to whom, who is around now, and who to page when
6− * a person is needed. It also carries the teams' budgets, which cap what
5+ * they own, who is around now, and who to page when a person is needed. It also carries the teams' budgets, which cap what
76 * the team's agents spend together in a month.
87 *
98 * `teamsSection` and `teamBudgetBlock` are pure, so they are tested on
112111 const role = [person.title, team.lead?.kind === "user" && team.lead.username === person.username ? "the lead" : null, person.maintainer ? "a maintainer" : null].filter(Boolean);
113112 if (role.length) bits.push(role.join(", "));
114113 if (person.owns.length) bits.push(`owns ${person.owns.join(", ")}`);
115− if (person.manager) bits.push(`reports to @${person.manager}`);
116114 const shows = showing(presence.get(person.user_id), person.timezone, now, known);
117115 const time = localTime(person.timezone, now);
118116 const now_ = [shows, time ? `it's ${time} for them` : null].filter(Boolean).join("; ");
+1−4
292292 WHERE team_id IN (SELECT id FROM teams WHERE workspace_id = ?) AND user_id = ?",
293293 )
294294 .bind(&[workspace_id.into(), user_id.into()])?,
295− // No one reports to them here any more, and they lead no team.
296− self.db
297− .prepare("UPDATE workspace_members SET manager_id = NULL WHERE workspace_id = ? AND manager_id = ?")
298− .bind(&[workspace_id.into(), user_id.into()])?,
295+ // They lead no team here any more.
299296 self.db
300297 .prepare("UPDATE teams SET lead_kind = NULL, lead_id = NULL WHERE workspace_id = ? AND lead_kind = 'user' AND lead_id = ?")
301298 .bind(&[workspace_id.into(), user_id.into()])?,
+2−55
11 //! A workspace's people: the directory of its members and teams, and each
2−//! member's place in it (a title, who they report to, what they own). See
2+//! member's place in it (a title and what they own). See
33 //! `g1t_contracts::people`. Agents live in the agents service; teams name
44 //! them by id.
5−
6−use std::collections::HashMap;
75
86 use g1t_contracts::audit::Surface;
97 use g1t_contracts::people::*;
5149 title: Option<String>,
5250 #[serde(default)]
5351 owns: Option<String>,
54− #[serde(default)]
55− manager: Option<String>,
5652 joined_at: String,
5753 }
5854
7167 timezone: blank(self.timezone),
7268 role: if self.role == "owner" { Role::Owner } else { Role::Member },
7369 title: self.title.as_deref().and_then(clean_title),
74− manager: self.manager,
7570 owns: owns_from(self.owns.as_deref()),
7671 joined_at: self.joined_at,
7772 }
8277 fn people_sql(filter: &str) -> String {
8378 format!(
8479 "SELECT u.id AS user_id, u.username, u.display_username, u.display_name AS name, u.avatar, u.bio, u.location,
85− u.pronouns, u.timezone, wm.role, wm.title, wm.owns, mu.username AS manager, wm.created_at AS joined_at
80+ u.pronouns, u.timezone, wm.role, wm.title, wm.owns, wm.created_at AS joined_at
8681 FROM workspace_members wm
8782 JOIN users u ON u.id = wm.user_id AND u.deleted_at IS NULL
8883 JOIN workspaces w ON w.id = wm.workspace_id AND w.deleted_at IS NULL
89− LEFT JOIN users mu ON mu.id = wm.manager_id AND mu.deleted_at IS NULL
9084 WHERE w.slug = ?1 {filter}
9185 ORDER BY lower(coalesce(u.display_name, u.username)) LIMIT 5000"
9286 )
249243 sets.push(format!("owns = ?{}", binds.len()));
250244 changed.push("what they own");
251245 }
252− if let Some(manager) = a.manager.as_deref().map(str::trim) {
253− if !may_edit_profile(ProfileField::Manager, is_self, owner) {
254− return Ok(Outcome::fail(FailureCode::Forbidden, format!("Only owners of {workspace} set who someone reports to.")));
255− }
256− if manager.is_empty() {
257− binds.push(JsValue::NULL);
258− } else {
259− let Some((manager_id, manager_name)) = self.person_by_username(manager).await? else {
260− return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_MEMBER));
261− };
262− if !self.is_member_of(&workspace_id, &manager_id).await? {
263− return Ok(Outcome::fail(
264− FailureCode::Invalid,
265− format!("{manager_name} is not a member of {workspace}."),
266− ));
267− }
268− let lines = self.reporting_lines(&workspace_id).await?;
269− if makes_loop(&user_id, &manager_id, |id| lines.get(id).cloned()) {
270− return Ok(Outcome::fail(
271− FailureCode::Invalid,
272− format!("{username} can't report to {manager_name}: {manager_name} already reports to {username}, directly or through others."),
273− ));
274− }
275− binds.push(manager_id.into());
276− }
277− sets.push(format!("manager_id = ?{}", binds.len()));
278− changed.push("manager");
279− }
280246 if !sets.is_empty() {
281247 binds.push(JsValue::from(workspace_id.clone()));
282248 binds.push(JsValue::from(user_id.clone()));
309275 Some(person) => Outcome::Ok(person.person()),
310276 None => Outcome::fail(FailureCode::NotFound, NO_SUCH_MEMBER),
311277 })
312− }
313−
314− /// Each member's manager in a workspace, by user id.
315− async fn reporting_lines(&self, workspace_id: &str) -> Result<HashMap<String, String>> {
316− #[derive(Deserialize)]
317− struct Line {
318− user_id: String,
319− manager_id: String,
320− }
321− Ok(self
322− .db
323− .prepare("SELECT user_id, manager_id FROM workspace_members WHERE workspace_id = ? AND manager_id IS NOT NULL")
324− .bind(&[workspace_id.into()])?
325− .all()
326− .await?
327− .results::<Line>()?
328− .into_iter()
329− .map(|line| (line.user_id, line.manager_id))
330− .collect())
331278 }
332279
333280 /// Every team of a workspace, as `viewer_id` stands in each.
+1−5
17721772 title: Option<String>,
17731773 #[serde(default)]
17741774 owns: Option<String>,
1775− #[serde(default)]
1776− manager: Option<String>,
17771775 }
17781776 #[derive(Deserialize)]
17791777 struct AgentRow {
17871785 self.db
17881786 .prepare(
17891787 "SELECT tm.team_id, u.id AS user_id, u.username, u.display_name AS name, u.timezone, tm.role,
1790− wm.title, wm.owns, mu.username AS manager
1788+ wm.title, wm.owns
17911789 FROM team_members tm
17921790 JOIN teams t ON t.id = tm.team_id
17931791 JOIN users u ON u.id = tm.user_id AND u.deleted_at IS NULL
17941792 LEFT JOIN workspace_members wm ON wm.workspace_id = t.workspace_id AND wm.user_id = u.id
1795− LEFT JOIN users mu ON mu.id = wm.manager_id AND mu.deleted_at IS NULL
17961793 WHERE tm.team_id IN (SELECT value FROM json_each(?1))
17971794 ORDER BY tm.role DESC, u.username LIMIT 2000",
17981795 )
18301827 title: person.title.clone(),
18311828 timezone: person.timezone.clone(),
18321829 owns: crate::people::owns_from(person.owns.as_deref()),
1833− manager: person.manager.clone(),
18341830 maintainer: person.role == TeamRole::Maintainer.as_str(),
18351831 })
18361832 .collect(),

Comments

Sign in to comment.