Skip to content
265 linesCodeBlameRaw
1import { Bot, Sparkles, Users } from "lucide-react";
2import { Form, Link, data, useSearchParams } from "react-router";
3
4import type { TeamRole } from "@g1t/contracts";
5
6import type { Route } from "./+types/members";
7import { AgentLink, AgentPersonFace, ToldText } from "../../../components/people";
8import { useTeam, useTeamAgents } from "../../../components/teams";
9import { Avatar, ErrorText, Field, Input, SubmitButton } from "../../../components/ui";
10import { Badge } from "../../../components/ui/badge";
11import { CheckboxOption } from "../../../components/ui/checkbox";
12import { SelectField } from "../../../components/ui/select";
13import { Switch } from "../../../components/ui/switch";
14import { WithPresence } from "../../../components/presence";
15import { agentsOn, leads, peopleAgent, personPath, teamBlock } from "../../../lib/people";
16import { teamPath } from "../../../lib/teams";
17import { identity, workspaceAgents } from "../../../lib/services.server";
18import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server";
19import { UserCard } from "../../../components/user-card";
20
21export async function loader({ params, context, request }: Route.LoaderArgs) {
22 const viewer = getViewer(context);
23 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
24 const children = new URL(request.url).searchParams.get("children") === "1";
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 };
45}
46
47export async function action({ request, params, context }: Route.ActionArgs) {
48 assertSameOrigin(request);
49 const user = requireUser(context, request);
50 const form = await request.formData();
51 const intent = String(form.get("intent") ?? "");
52 if (intent === "add-agent" || intent === "remove-agent") {
53 const agentId = String(form.get("agent_id") ?? "");
54 // Identity knows agents only by id: check it is one of this workspace's.
55 const listed = await workspaceAgents.list(params.owner, user).catch(() => null);
56 const agent = listed?.ok ? listed.value.find((a) => a.id === agentId) : null;
57 if (!agent) return { intent, username: agentId, error: "Choose one of the workspace's agents." };
58 const done =
59 intent === "add-agent"
60 ? await identity.setTeamAgent(user, params.owner, params.team, agent.id)
61 : await identity.removeTeamAgent(user, params.owner, params.team, agent.id);
62 return done.ok ? { intent, username: agentId, error: null } : { intent, username: agentId, error: done.error.message };
63 }
64 const username = String(form.get("username") ?? "").trim().replace(/^@/, "");
65 if (!username) return { intent, username, error: "Enter a username." };
66 if (intent === "remove") {
67 const removed = await identity.removeTeamMember(user, params.owner, params.team, username);
68 return removed.ok ? { intent, username, error: null } : { intent, username, error: removed.error.message };
69 }
70 const role: TeamRole = form.get("role") === "maintainer" ? "maintainer" : "member";
71 const set = await identity.setTeamMember(user, params.owner, params.team, username, role);
72 return set.ok ? { intent, username, error: null } : { intent, username, error: set.error.message };
73}
74
75export default function TeamMembers({ loaderData, actionData }: Route.ComponentProps) {
76 const team = useTeam();
77 const { agents, added } = useTeamAgents();
78 const { members, children, me, addable, told } = loaderData;
79 const [, setParams] = useSearchParams();
80 const manage = team.can_manage;
81 const rowError = (username: string) =>
82 actionData && actionData.intent !== "add" && actionData.intent !== "add-agent" && actionData.username === username ? actionData.error : null;
83
84 return (
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>
103
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>
131 </Link>
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 )}
149 <Form method="post">
150 <input type="hidden" name="intent" value="remove" />
151 <input type="hidden" name="username" value={member.username} />
152 <SubmitButton variant="quiet" match={{ intent: "remove", username: member.username }} pending="Removing…">
153 {self ? "Leave" : "Remove"}
154 </SubmitButton>
155 </Form>
156 </span>
157 )}
158 </div>
159 <ErrorText>{rowError(member.username)}</ErrorText>
160 </li>
161 );
162 })}
163 </ul>
164 )}
165
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>
184
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…">
240 Add
241 </SubmitButton>
242 </Form>
243 )}
244 {actionData?.intent === "add-agent" && <ErrorText>{actionData.error}</ErrorText>}
245 </section>
246
247 {told && (
248 <section aria-labelledby="team-told" className="rounded-xl border border-accent/40 bg-surface p-4">
249 <div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
250 <h2 id="team-told" className="flex items-center gap-2 text-sm font-medium">
251 <Sparkles size={14} className="text-accent" />
252 What the agents on {team.name} know about it
253 </h2>
254 <span className="text-xs text-faint">As {told.agent.display_name} is told it every turn, from this page and who's around now</span>
255 </div>
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>
262 )}
263 </div>
264 );
265}