Skip to content
271 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, AgentTag, ToldText } from "../../../components/people";
8import { useTeam, useTeamAgents } from "../../../components/teams";
9import { ErrorText, Field, Input, SubmitButton } from "../../../components/ui";
10import { Avatar } from "../../../components/ui/avatar";
11import { Badge } from "../../../components/ui/badge";
12import { Card } from "../../../components/ui/card";
13import { CheckboxOption } from "../../../components/ui/checkbox";
14import { SelectField } from "../../../components/ui/select";
15import { Switch } from "../../../components/ui/switch";
16import { WithPresence } from "../../../components/presence";
17import { agentsOn, leads, peopleAgent, personPath, teamBlock } from "../../../lib/people";
18import { teamPath } from "../../../lib/teams";
19import { identity, workspaceAgents } from "../../../lib/services.server";
20import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server";
21import { UserCard } from "../../../components/user-card";
22
23export async function loader({ params, context, request }: Route.LoaderArgs) {
24 const viewer = getViewer(context);
25 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
26 const children = new URL(request.url).searchParams.get("children") === "1";
27 const [members, team, added, listed] = await Promise.all([
28 identity.teamMembers(viewer, params.owner, params.team, children).then(unwrap),
29 identity.getTeam(viewer, params.owner, params.team).then(unwrap),
30 identity.teamAgents(viewer, params.owner, params.team).catch(() => null),
31 workspaceAgents.list(params.owner, viewer!).catch(() => null),
32 ]);
33 const all = listed?.ok ? listed.value.map(peopleAgent) : [];
34 const ids = added?.ok ? added.value.map((agent) => agent.agent_id) : [];
35 const onTeam = agentsOn({ agent_ids: ids }, all);
36 // What the team's first agent is told about it, word for word: every agent on it is told the same about this team.
37 const first = onTeam.find((agent) => !agent.builtin);
38 const told = first ? await workspaceAgents.teamContext(params.owner, first.handle, viewer!).catch(() => null) : null;
39 return {
40 members,
41 children,
42 me: viewer?.username ?? null,
43 // The workspace's agents not on the team yet, for adding one.
44 addable: all.filter((agent) => !agent.builtin && !onTeam.some((on) => on.id === agent.id)).map(({ id, display_name, title, role }) => ({ id, display_name, title, role })),
45 told: first && told?.ok ? { agent: first, text: teamBlock(told.value.text, team.name) } : null,
46 };
47}
48
49export async function action({ request, params, context }: Route.ActionArgs) {
50 assertSameOrigin(request);
51 const user = requireUser(context, request);
52 const form = await request.formData();
53 const intent = String(form.get("intent") ?? "");
54 if (intent === "add-agent" || intent === "remove-agent") {
55 const agentId = String(form.get("agent_id") ?? "");
56 // Identity knows agents only by id: check it is one of this workspace's.
57 const listed = await workspaceAgents.list(params.owner, user).catch(() => null);
58 const agent = listed?.ok ? listed.value.find((a) => a.id === agentId) : null;
59 if (!agent) return { intent, username: agentId, error: "Choose one of the workspace's agents." };
60 const done =
61 intent === "add-agent"
62 ? await identity.setTeamAgent(user, params.owner, params.team, agent.id)
63 : await identity.removeTeamAgent(user, params.owner, params.team, agent.id);
64 return done.ok ? { intent, username: agentId, error: null } : { intent, username: agentId, error: done.error.message };
65 }
66 const username = String(form.get("username") ?? "").trim().replace(/^@/, "");
67 if (!username) return { intent, username, error: "Enter a username." };
68 if (intent === "remove") {
69 const removed = await identity.removeTeamMember(user, params.owner, params.team, username);
70 return removed.ok ? { intent, username, error: null } : { intent, username, error: removed.error.message };
71 }
72 const role: TeamRole = form.get("role") === "maintainer" ? "maintainer" : "member";
73 const set = await identity.setTeamMember(user, params.owner, params.team, username, role);
74 return set.ok ? { intent, username, error: null } : { intent, username, error: set.error.message };
75}
76
77export default function TeamMembers({ loaderData, actionData }: Route.ComponentProps) {
78 const team = useTeam();
79 const { agents } = useTeamAgents();
80 const { members, children, me, addable, told } = loaderData;
81 const [, setParams] = useSearchParams();
82 const manage = team.can_manage;
83 const rowError = (username: string) =>
84 actionData && actionData.intent !== "add" && actionData.intent !== "add-agent" && actionData.username === username ? actionData.error : null;
85
86 return (
87 <div className="max-w-3xl space-y-10">
88 <section aria-labelledby="team-people" className="space-y-4">
89 <div className="flex flex-wrap items-center justify-between gap-3">
90 <h2 id="team-people" className="flex items-center gap-2 font-medium">
91 <Users size={15} className="text-faint" />
92 People <span className="text-sm font-normal text-faint tabular-nums">{team.members_count}</span>
93 </h2>
94 {team.child_teams_count > 0 && (
95 <label className="flex items-center gap-2.5 text-sm text-muted">
96 <Switch
97 size="sm"
98 checked={children}
99 onCheckedChange={(on) => setParams(on ? { children: "1" } : {}, { replace: true, preventScrollReset: true })}
100 />
101 Include the people of child teams
102 </label>
103 )}
104 </div>
105
106 {members.length === 0 ? (
107 <Card asChild tone="plain" className="border-dashed px-6 py-6 text-center text-sm text-muted">
108 <p>
109 {agents.length ? `No people on ${team.name}: it's agents only.` : `No one is in ${team.name} yet.`}
110 {manage && " Add members of the workspace below."}
111 </p>
112 </Card>
113 ) : (
114 <Card asChild tone="plain" divided>
115 <ul>
116 {members.map((member) => {
117 const self = member.username === me;
118 return (
119 <li key={member.username} className="px-4 py-3">
120 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
121 <WithPresence person={{ username: member.username }} size={28}>
122 <Avatar name={member.username} image={member.avatar} size={28} />
123 </WithPresence>
124 <div className="min-w-0 grow basis-32 truncate">
125 <UserCard username={member.username}>
126 <Link to={personPath(team.workspace, member.username)} className="text-sm font-medium hover:text-accent">
127 {member.name || member.username}
128 </Link>
129 </UserCard>
130 {member.name && <span className="ml-2 hidden font-mono text-xs text-faint sm:inline">{member.username}</span>}
131 </div>
132 {leads(team.lead, { username: member.username }) && <Badge tone="accent">Lead</Badge>}
133 {member.via ? (
134 <Link to={teamPath(team.workspace, member.via)}>
135 <Badge>via {member.via}</Badge>
136 </Link>
137 ) : member.role === "maintainer" ? (
138 <Badge tone="accent">Maintainer</Badge>
139 ) : (
140 <Badge>Member</Badge>
141 )}
142 {!member.via && (manage || self) && (
143 <span className="flex items-center gap-1">
144 {manage && (
145 <Form method="post">
146 <input type="hidden" name="intent" value="role" />
147 <input type="hidden" name="username" value={member.username} />
148 <input type="hidden" name="role" value={member.role === "maintainer" ? "member" : "maintainer"} />
149 <SubmitButton variant="outline" match={{ intent: "role", username: member.username }} pending="Saving…">
150 {member.role === "maintainer" ? "Make member" : "Make maintainer"}
151 </SubmitButton>
152 </Form>
153 )}
154 <Form method="post">
155 <input type="hidden" name="intent" value="remove" />
156 <input type="hidden" name="username" value={member.username} />
157 <SubmitButton variant="outline" match={{ intent: "remove", username: member.username }} pending="Removing…">
158 {self ? "Leave" : "Remove"}
159 </SubmitButton>
160 </Form>
161 </span>
162 )}
163 </div>
164 <ErrorText>{rowError(member.username)}</ErrorText>
165 </li>
166 );
167 })}
168 </ul>
169 </Card>
170 )}
171
172 {manage && (
173 <Form method="post" key={members.length} className="flex flex-col gap-3 sm:flex-row sm:items-start">
174 <input type="hidden" name="intent" value="add" />
175 <div className="grow">
176 <Field label="Add a person" hint={`A member of ${team.workspace}, by username. Maintainers manage the team's people, agents and settings.`}>
177 <Input name="username" required maxLength={39} placeholder="username" />
178 </Field>
179 </div>
180 <CheckboxOption name="role" value="maintainer" label="Maintainer" className="sm:pt-[2.1rem]" />
181 <div className="sm:pt-[1.625rem]">
182 <SubmitButton match={{ intent: "add" }} pending="Adding…">
183 Add
184 </SubmitButton>
185 </div>
186 </Form>
187 )}
188 {actionData?.intent === "add" && <ErrorText>{actionData.error}</ErrorText>}
189 </section>
190
191 <section aria-labelledby="team-agents" className="space-y-4">
192 <h2 id="team-agents" className="flex items-center gap-2 font-medium">
193 <Bot size={15} className="text-faint" />
194 Agents <span className="text-sm font-normal text-faint tabular-nums">{agents.length}</span>
195 </h2>
196 {agents.length === 0 ? (
197 <Card asChild tone="plain" className="border-dashed px-6 py-6 text-center text-sm text-muted">
198 <p>
199 No agents on {team.name}: it's people only.{manage && addable.length > 0 && " Add one below."}
200 </p>
201 </Card>
202 ) : (
203 <Card asChild tone="plain" divided>
204 <ul>
205 {agents.map((agent) => {
206 return (
207 <li key={agent.id} className="px-4 py-3">
208 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
209 <AgentPersonFace agent={agent} size={28} />
210 <div className="min-w-0 grow basis-32 truncate text-sm">
211 <AgentLink workspace={team.workspace} agent={agent} className="font-medium" />
212 <span className="ml-2 text-muted">{agent.title || agent.role}</span>
213 </div>
214 <AgentTag />
215 {leads(team.lead, { id: agent.id }) && <Badge tone="accent">Lead</Badge>}
216 {manage && (
217 <Form method="post">
218 <input type="hidden" name="intent" value="remove-agent" />
219 <input type="hidden" name="agent_id" value={agent.id} />
220 <SubmitButton variant="outline" match={{ intent: "remove-agent", agent_id: agent.id }} pending="Removing…">
221 Remove
222 </SubmitButton>
223 </Form>
224 )}
225 </div>
226 <ErrorText>{rowError(agent.id)}</ErrorText>
227 </li>
228 );
229 })}
230 </ul>
231 </Card>
232 )}
233 {manage && addable.length > 0 && (
234 <Form method="post" key={`agents:${agents.length}`} className="flex flex-col gap-3 sm:flex-row sm:items-end">
235 <input type="hidden" name="intent" value="add-agent" />
236 <label className="block grow">
237 <span className="mb-1.5 block text-sm font-medium text-muted">Add an agent</span>
238 <SelectField
239 name="agent_id"
240 defaultValue={addable[0]!.id}
241 className="w-full"
242 options={addable.map((agent) => ({ value: agent.id, label: `${agent.display_name} · ${agent.title || agent.role}` }))}
243 />
244 </label>
245 <SubmitButton match={{ intent: "add-agent" }} pending="Adding…">
246 Add
247 </SubmitButton>
248 </Form>
249 )}
250 {actionData?.intent === "add-agent" && <ErrorText>{actionData.error}</ErrorText>}
251 </section>
252
253 {told && (
254 <section aria-labelledby="team-told" className="rounded-xl border border-accent/40 bg-surface p-4">
255 <div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
256 <h2 id="team-told" className="flex items-center gap-2 text-sm font-medium">
257 <Sparkles size={14} className="text-accent" />
258 What the agents on {team.name} know about it
259 </h2>
260 <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>
261 </div>
262 {told.text ? (
263 <ToldText text={told.text} />
264 ) : (
265 <p className="text-sm text-faint">{team.visibility === "secret" ? "Agents aren't told about secret teams." : "It couldn't be read just now."}</p>
266 )}
267 </section>
268 )}
269 </div>
270 );
271}