Skip to content
661 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge main (membership, two-factor, GitHub repo roles) into tokens1import { ArrowUpRight, Check, Ellipsis, ShieldAlert, ShieldCheck, Users } from "lucide-react";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2import { useState } from "react";
Merge main (membership, two-factor, GitHub repo roles) into tokens3import { Form, Link, data, redirect, useSearchParams, useSubmit } from "react-router";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look4
5import {
6 BASE_PERMISSIONS,
7 BASE_PERMISSION_LABELS,
8 type BasePermission,
9 DEFAULT_BASE_PERMISSION,
Merge main (membership, two-factor, GitHub repo roles) into tokens10 type Member,
11 ORG_ROLES,
12 ORG_ROLE_LABELS,
13 ORG_ROLE_SUMMARIES,
14 type OrgRole,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look15 REPO_ROLE_LABELS,
16} from "@g1t/contracts";
Agents as a team: lifecycle, merge queue, billing and a new shell17
18import type { Route } from "./+types/people";
Social cards for every page: og.g1t.sh19import { page } from "../../lib/meta";
Merge main (membership, two-factor, GitHub repo roles) into tokens20import { Avatar, Button, CopyLine, ErrorText, Field, Input, Pill, SubmitButton, TimeAgo } from "../../components/ui";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look21import { Badge } from "../../components/ui/badge";
Merge main (membership, two-factor, GitHub repo roles) into tokens22import {
23 AlertDialog,
24 AlertDialogCancel,
25 AlertDialogContent,
26 AlertDialogDescription,
27 AlertDialogFooter,
28 AlertDialogHeader,
29 AlertDialogTitle,
30} from "../../components/ui/alert-dialog";
31import {
32 DropdownMenu,
33 DropdownMenuContent,
34 DropdownMenuItem,
35 DropdownMenuLabel,
36 DropdownMenuSeparator,
37 DropdownMenuTrigger,
38} from "../../components/ui/dropdown-menu";
39import { Hint } from "../../components/ui/hint";
40import { forgetWorkspace } from "../../lib/workspace-choice";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look41import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
42import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs";
43import { inviteLink, inviteState, moreInvitesMailto } from "../../lib/invites";
Merge Stripe Tax, the card fee on card payments, and one free workspace per person44import { billing, identity } from "../../lib/services.server";
45import { StartPlanToInvite } from "../../components/start-plan";
Agents as a team: lifecycle, merge queue, billing and a new shell46import {
47 assertSameOrigin,
48 getViewer,
49 requireUser,
50 roleIn,
51 unwrap,
52} from "../../lib/session.server";
Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016)53import { UserCard } from "../../components/user-card";
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)54import { PeoplePicker } from "../../components/people-picker";
55import { inviteTarget } from "../../lib/people-search";
Agents as a team: lifecycle, merge queue, billing and a new shell56
Social cards for every page: og.g1t.sh57export function meta({ params, ...args }: Route.MetaArgs) {
Navigation: the workspace's own page with tabs, pins and recent in the sidebar58 return page(args, { title: `People · ${params.owner} · g1t` });
Agents as a team: lifecycle, merge queue, billing and a new shell59}
60
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look61export async function loader({ params, context, request }: Route.LoaderArgs) {
Agents as a team: lifecycle, merge queue, billing and a new shell62 const viewer = getViewer(context);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look63 const role = roleIn(viewer, params.owner);
64 // Who is in a workspace is its members' business; anyone else, including
65 // someone a repository is shared with, gets nothing here.
66 if (!role) throw data(null, { status: 404 });
67 const owner = role === "owner";
Merge Stripe Tax, the card fee on card payments, and one free workspace per person68 const [members, invites, workspace, outside, teams, free] = await Promise.all([
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look69 identity.listMembers(params.owner, viewer),
70 owner ? identity.workspaceInvites(params.owner, viewer).catch(() => null) : null,
71 identity.getWorkspace(params.owner),
72 owner ? identity.outsideCollaborators(viewer, params.owner).catch(() => null) : null,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar73 // Each member's teams, as the viewer may see them.
74 identity.teamMemberships(viewer, params.owner).catch(() => null),
Merge Stripe Tax, the card fee on card payments, and one free workspace per person75 // A free workspace adds no one until it starts the plan; identity
76 // refuses it either way, so a failure here only hides the note.
77 billing.freeWorkspaces([params.owner]).catch(() => [] as string[]),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look78 ]);
Agents as a team: lifecycle, merge queue, billing and a new shell79 return {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look80 role,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person81 free: free.includes(params.owner.toLowerCase()),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look82 members: unwrap(members),
83 invites: invites?.ok ? invites.value : [],
84 base: workspace?.basePermission ?? DEFAULT_BASE_PERMISSION,
85 outside: outside?.ok ? outside.value : [],
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar86 teams: Object.fromEntries((teams?.ok ? teams.value : []).map((person) => [person.username, person.teams])),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look87 origin: new URL(request.url).origin,
Merge main (membership, two-factor, GitHub repo roles) into tokens88 me: viewer?.username ?? null,
Agents as a team: lifecycle, merge queue, billing and a new shell89 };
90}
91
92export async function action({ request, params, context }: Route.ActionArgs) {
93 assertSameOrigin(request);
94 const user = requireUser(context, request);
95 const form = await request.formData();
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look96 if (form.get("action") === "revoke-invite") {
97 const result = await identity.revokeWorkspaceInvite(user, params.owner, String(form.get("id") ?? ""));
98 return result.ok ? null : { error: result.error.message };
99 }
Merge main (membership, two-factor, GitHub repo roles) into tokens100 const target = String(form.get("member") ?? "").trim();
101 // Owner or member, and the roles that add to a member.
102 if (form.get("action") === "role") {
103 const role = form.get("role") === "owner" ? "owner" : "member";
104 const result = await identity.updateMember(user, params.owner, target, { role });
105 return result.ok ? { changed: target } : { error: result.error.message, row: target };
106 }
107 if (form.get("action") === "org-roles") {
108 const roles = String(form.get("org_roles") ?? "")
109 .split(",")
110 .filter((role): role is OrgRole => (ORG_ROLES as readonly string[]).includes(role));
111 const result = await identity.updateMember(user, params.owner, target, { org_roles: roles });
112 return result.ok ? { changed: target } : { error: result.error.message, row: target };
113 }
114 if (form.get("action") === "transfer") {
115 const result = await identity.transferOwnership(user, params.owner, target);
116 return result.ok ? { transferred: target } : { error: result.error.message, row: target };
117 }
118 if (form.get("action") === "leave") {
119 const result = await identity.leaveWorkspace(user, params.owner);
120 if (!result.ok) return { error: result.error.message, leaving: true };
121 const secure = new URL(request.url).protocol === "https:";
122 throw redirect("/", { headers: { "Set-Cookie": forgetWorkspace(secure) } });
123 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look124 if (form.get("action") === "base-permission") {
125 const base = String(form.get("base") ?? "");
126 if (!(BASE_PERMISSIONS as readonly string[]).includes(base)) return { error: "Choose a base permission." };
127 const result = await identity.setBasePermission(user, params.owner, base as BasePermission);
128 return result.ok ? { based: result.value } : { error: result.error.message, base: true };
129 }
130 const member = String(form.get("member") ?? "").trim();
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)131 if (form.get("action") === "remove") {
132 const result = await identity.removeMember(user, params.owner, member);
133 return result.ok ? null : { error: result.error.message, converting: false };
134 }
135 // Nobody joins without saying yes: a username or an address gets an
136 // invitation to accept or decline, with the role chosen here.
137 const who = inviteTarget(member);
138 if (!who) return { error: "Enter a g1t username or an email address.", outOfInvites: false };
139 const role = form.get("role") === "owner" ? "owner" : "member";
140 const result = await identity.inviteMember(user, params.owner, { ...who, role });
141 if (!result.ok) {
142 return { error: result.error.message, outOfInvites: result.error.code === "limit", converting: form.get("action") === "convert" };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look143 }
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)144 if (form.get("action") === "convert") return { converted: member };
145 return { invited: "email" in who ? who.email : `@${"username" in who ? who.username : member}`, outOfInvites: false };
Agents as a team: lifecycle, merge queue, billing and a new shell146}
147
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look148/** What the base permission means for members, in a sentence. */
149const BASE_MEANS: Record<BasePermission, string> = {
150 none: "Members see only public repositories and the ones they are given a role on.",
151 read: "Members can see and clone every repository, open issues and pull requests, and comment.",
152 write: "Members can see every repository, manage its issues and pull requests, push, merge, and put agents to work.",
153 admin: "Members can do everything on every repository, including its settings, webhooks, secrets and who has access. Only owners transfer or delete one.",
154};
155
Merge main (membership, two-factor, GitHub repo roles) into tokens156/** What a member's roles say about them, as badges: owner or member, then the roles on top. */
157function RoleBadges({ member }: { member: Member }) {
158 return (
159 <span className="flex flex-wrap justify-end gap-1">
160 {member.role === "owner" ? <Badge tone="accent">Owner</Badge> : <Badge>Member</Badge>}
161 {(member.org_roles ?? []).map((role) => (
162 <Hint key={role} label={ORG_ROLE_SUMMARIES[role]}>
163 <Badge tone="info" tabIndex={0}>
164 {ORG_ROLE_LABELS[role]}
165 </Badge>
166 </Hint>
167 ))}
168 </span>
169 );
170}
171
172/** Whether a member has two-factor authentication on, for owners. */
173function TwoFactorMark({ on }: { on: boolean | null | undefined }) {
174 if (on == null) return null;
175 return on ? (
176 <Hint label="Two-factor authentication is on">
177 <span tabIndex={0} className="inline-flex text-success" aria-label="Two-factor authentication on">
178 <ShieldCheck size={15} />
179 </span>
180 </Hint>
181 ) : (
182 <Hint label="Two-factor authentication is off">
183 <span tabIndex={0} className="inline-flex text-warn" aria-label="Two-factor authentication off">
184 <ShieldAlert size={15} />
185 </span>
186 </Hint>
187 );
188}
189
190/** An owner's menu for one member: their role, the roles on top, handing over and removing. */
191function MemberMenu({ member, self, owners, slug }: { member: Member; self: boolean; owners: number; slug: string }) {
192 const submit = useSubmit();
193 const [confirm, setConfirm] = useState<"transfer" | "remove" | null>(null);
194 const roles = member.org_roles ?? [];
195 const post = (fields: Record<string, string>) =>
196 submit({ ...fields, member: member.username }, { method: "post", preventScrollReset: true });
197 const lastOwner = member.role === "owner" && owners <= 1;
198 return (
199 <>
200 <DropdownMenu>
201 <DropdownMenuTrigger asChild>
202 <button
203 type="button"
204 aria-label={`Manage ${member.username}`}
205 className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-fg"
206 >
207 <Ellipsis size={16} />
208 </button>
209 </DropdownMenuTrigger>
210 <DropdownMenuContent align="end" className="w-64">
211 <DropdownMenuLabel>Role in {slug}</DropdownMenuLabel>
212 {member.role === "member" ? (
213 <DropdownMenuItem onSelect={() => post({ action: "role", role: "owner" })}>Make owner</DropdownMenuItem>
214 ) : (
215 <DropdownMenuItem disabled={lastOwner} onSelect={() => post({ action: "role", role: "member" })}>
216 {lastOwner ? "The only owner" : "Make member"}
217 </DropdownMenuItem>
218 )}
219 <DropdownMenuSeparator />
220 <DropdownMenuLabel>Also</DropdownMenuLabel>
221 {ORG_ROLES.map((role) => {
222 const has = roles.includes(role);
223 const next = has ? roles.filter((other) => other !== role) : [...roles, role];
224 return (
225 <DropdownMenuItem key={role} onSelect={() => post({ action: "org-roles", org_roles: next.join(",") })}>
226 <span className="flex size-4 items-center justify-center">{has && <Check />}</span>
227 {ORG_ROLE_LABELS[role]}
228 </DropdownMenuItem>
229 );
230 })}
231 {!self && (
232 <>
233 <DropdownMenuSeparator />
234 {member.role === "member" && (
235 <DropdownMenuItem onSelect={() => setConfirm("transfer")}>Transfer ownership…</DropdownMenuItem>
236 )}
237 <DropdownMenuItem disabled={lastOwner} onSelect={() => setConfirm("remove")} className="text-danger">
238 Remove from {slug}…
239 </DropdownMenuItem>
240 </>
241 )}
242 </DropdownMenuContent>
243 </DropdownMenu>
244 <AlertDialog open={confirm !== null} onOpenChange={(open) => !open && setConfirm(null)}>
245 <AlertDialogContent>
246 <Form method="post" className="grid gap-4" onSubmit={() => setConfirm(null)}>
247 <input type="hidden" name="action" value={confirm === "transfer" ? "transfer" : "remove"} />
248 <input type="hidden" name="member" value={member.username} />
249 <AlertDialogHeader>
250 <AlertDialogTitle>
251 {confirm === "transfer" ? `Hand ${slug} to ${member.username}?` : `Remove ${member.username} from ${slug}?`}
252 </AlertDialogTitle>
253 <AlertDialogDescription>
254 {confirm === "transfer"
255 ? `${member.username} becomes an owner and you a member. Only an owner can make you an owner again.`
256 : `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.`}
257 </AlertDialogDescription>
258 </AlertDialogHeader>
259 <AlertDialogFooter>
260 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
261 <SubmitButton variant="danger" match={{ action: confirm ?? "", member: member.username }} pending="Working…">
262 {confirm === "transfer" ? "Transfer ownership" : "Remove"}
263 </SubmitButton>
264 </AlertDialogFooter>
265 </Form>
266 </AlertDialogContent>
267 </AlertDialog>
268 </>
269 );
270}
271
272/** Leaving the workspace, for anyone in it. */
273function LeaveSection({ slug, soleOwner, error }: { slug: string; soleOwner: boolean; error: string | null }) {
274 const [open, setOpen] = useState(false);
275 return (
276 <section id="leave" className="mt-10 scroll-mt-20 border-t border-line pt-8">
277 <h2 className="font-medium">Leave {slug}</h2>
278 <p className="mt-1 text-sm text-muted">
279 {soleOwner
280 ? `You are ${slug}'s only owner. Make another member an owner first, or delete the workspace in its settings.`
281 : "Your roles on its repositories and your place in its teams go too. An owner can add you again."}
282 </p>
283 <div className="mt-4">
284 <Button variant="danger" type="button" disabled={soleOwner} onClick={() => setOpen(true)}>
285 Leave {slug}
286 </Button>
287 </div>
288 <ErrorText>{error}</ErrorText>
289 <AlertDialog open={open} onOpenChange={setOpen}>
290 <AlertDialogContent>
291 <Form method="post" className="grid gap-4">
292 <input type="hidden" name="action" value="leave" />
293 <AlertDialogHeader>
294 <AlertDialogTitle>Leave {slug}?</AlertDialogTitle>
295 <AlertDialogDescription>You lose access to its private repositories at once.</AlertDialogDescription>
296 </AlertDialogHeader>
297 <AlertDialogFooter>
298 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
299 <SubmitButton variant="danger" match={{ action: "leave" }} pending="Leaving…">
300 Leave
301 </SubmitButton>
302 </AlertDialogFooter>
303 </Form>
304 </AlertDialogContent>
305 </AlertDialog>
306 </section>
307 );
308}
309
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look310export default function WorkspacePeople({ loaderData, actionData, params }: Route.ComponentProps) {
Merge main (membership, two-factor, GitHub repo roles) into tokens311 const { role, members, invites, origin, base, outside, teams, free, me } = loaderData;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look312 const owner = role === "owner";
Merge main (membership, two-factor, GitHub repo roles) into tokens313 const owners = members.filter((member) => member.role === "owner").length;
314 const rowError = (username: string) =>
315 actionData && "row" in actionData && actionData.row === username ? (actionData.error ?? null) : null;
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)316 // Waiting to be used, or used by someone still confirming their email:
317 // either can be revoked.
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)318 // Waiting for an answer to a workspace invitation, too.
319 const pending = invites.filter(
320 (invite) => invite.status === "pending" || invite.status === "awaiting_confirmation" || invite.status === "awaiting_answer",
321 );
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look322 const [search, setSearch] = useSearchParams();
323 const tab = search.get("tab") === "outside" && owner ? "outside" : "members";
324 const membersTab = (
325 <>
A catalogue of model providers, and settings that feel like settings326 <ul className="divide-y divide-line rounded-xl border border-line">
Agents as a team: lifecycle, merge queue, billing and a new shell327 {members.map((member) => (
Merge main (membership, two-factor, GitHub repo roles) into tokens328 <li key={member.username} className="flex flex-wrap items-center gap-3 px-4 py-3">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look329 <Avatar name={member.username} image={member.avatar} size={28} />
330 <div className="min-w-0 grow truncate">
Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016)331 <UserCard username={member.username}>
332 <Link to={`/u/${member.username}`} className="font-mono text-sm hover:text-accent">
333 {member.username}
334 </Link>
335 </UserCard>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look336 {member.name && <span className="ml-2 hidden text-sm text-muted sm:inline">{member.name}</span>}
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar337 {(teams[member.username] ?? []).length > 0 && (
338 <div className="mt-1 flex flex-wrap gap-1">
339 {(teams[member.username] ?? []).slice(0, 3).map((team) => (
340 <Link
341 key={team.slug}
342 to={`/${params.owner}/-/teams/${team.slug}`}
343 className="rounded-full border border-line px-2 py-px text-xs text-muted transition-colors hover:border-line-strong hover:text-fg"
344 >
345 {team.name}
346 </Link>
347 ))}
348 {(teams[member.username] ?? []).length > 3 && (
349 <span className="px-1 text-xs text-faint">+{(teams[member.username] ?? []).length - 3} more</span>
350 )}
351 </div>
352 )}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look353 </div>
Merge main (membership, two-factor, GitHub repo roles) into tokens354 {/* Together, so on a narrow screen they move under the name as one. */}
355 <div className="ml-auto flex items-center gap-2">
356 {owner && <TwoFactorMark on={member.two_factor} />}
357 <RoleBadges member={member} />
358 {owner && <MemberMenu member={member} self={member.username === me} owners={owners} slug={params.owner} />}
359 </div>
360 {rowError(member.username) && (
361 <p className="basis-full text-sm text-danger" role="alert">
362 {rowError(member.username)}
363 </p>
Agents as a team: lifecycle, merge queue, billing and a new shell364 )}
365 </li>
366 ))}
367 </ul>
Merge Stripe Tax, the card fee on card payments, and one free workspace per person368 {owner && free && (
369 <div className="mt-6">
370 <StartPlanToInvite workspace={params.owner} owner={owner} />
371 </div>
372 )}
373 {owner && !free && (
Merge branch 'worktree-agent-a985c01f43538af01'374 // Empty again once the person is on the list; kept as typed when it failed.
375 <Form
376 method="post"
377 key={`${members.length}:${pending.length}`}
378 className="mt-6 flex flex-col gap-3 sm:flex-row sm:items-start"
379 >
380 <input type="hidden" name="action" value="add" />
Agents as a team: lifecycle, merge queue, billing and a new shell381 <div className="grow">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look382 <Field
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)383 label="Invite someone"
384 hint="Search people on g1t by username or name, or enter an email address. They get an invitation in their inbox and by email, and join once they accept. An address without a g1t account gets an invite to make one, using one of your invites."
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look385 >
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)386 <PeoplePicker name="member" placeholder="username, name or name@example.com" />
Agents as a team: lifecycle, merge queue, billing and a new shell387 </Field>
388 </div>
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)389 <label className="block">
390 <span className="mb-1.5 block text-sm font-medium text-muted">Role</span>
391 <select
392 name="role"
393 defaultValue="member"
394 className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none hover:border-line-strong focus:border-accent-dim sm:w-auto"
395 >
396 <option value="member">Member</option>
397 <option value="owner">Owner</option>
398 </select>
399 </label>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look400 <div className="sm:pt-[1.625rem]">
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)401 <SubmitButton match={{ action: "add" }} pending="Inviting…">
402 Invite
Merge branch 'worktree-agent-a985c01f43538af01'403 </SubmitButton>
Agents as a team: lifecycle, merge queue, billing and a new shell404 </div>
405 </Form>
406 )}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look407 {actionData && "invited" in actionData && actionData.invited && (
408 <p className="text-sm text-muted" role="status">
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)409 Invitation sent to <span className="text-fg">{actionData.invited}</span>. They join once they accept.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look410 </p>
411 )}
Merge main (membership, two-factor, GitHub repo roles) into tokens412 {actionData && "transferred" in actionData && (
413 <p className="text-sm text-muted" role="status">
414 <span className="font-mono text-fg">{actionData.transferred}</span> owns {params.owner} now, and you are a member.
415 </p>
416 )}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look417 {actionData && "converted" in actionData && (
418 <p className="text-sm text-muted" role="status">
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)419 <span className="font-mono text-fg">{actionData.converted}</span> is invited to join {params.owner} as a member, and joins once they accept.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look420 </p>
421 )}
Merge main (membership, two-factor, GitHub repo roles) into tokens422 {actionData &&
423 !("base" in actionData) &&
424 !("row" in actionData) &&
425 !("leaving" in actionData) &&
426 !("converting" in actionData && actionData.converting) && (
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look427 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
428 )}
429 {actionData && "outOfInvites" in actionData && actionData.outOfInvites && (
430 <p className="mt-1 text-sm text-muted">
431 <a href={moreInvitesMailto(params.owner)} className="text-accent underline underline-offset-4">
432 Ask for more invites
433 </a>{" "}
434 for {params.owner}.
435 </p>
436 )}
437
438 {owner && pending.length > 0 && (
439 <section className="mt-8">
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)440 <h2 className="text-sm font-medium">Pending invitations</h2>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look441 <ul className="mt-3 divide-y divide-line rounded-xl border border-line">
442 {pending.map((invite) => (
443 <li key={invite.id} className="space-y-2 px-4 py-3">
444 <div className="flex flex-wrap items-center gap-3">
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)445 <span className="min-w-0 grow truncate text-sm">
446 {invite.email ??
447 (invite.invitee ? (
448 <UserCard username={invite.invitee}>
449 <Link to={`/u/${invite.invitee}`} className="font-mono hover:text-accent">
450 @{invite.invitee}
451 </Link>
452 </UserCard>
453 ) : (
454 "Anyone with the link"
455 ))}
456 </span>
457 {invite.role === "owner" && <Pill>Owner</Pill>}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look458 <Pill>{inviteState(invite).label}</Pill>
459 <Form method="post">
460 <input type="hidden" name="action" value="revoke-invite" />
461 <input type="hidden" name="id" value={invite.id} />
Merge branch 'worktree-agent-a985c01f43538af01'462 <SubmitButton variant="quiet" match={{ action: "revoke-invite", id: invite.id }} pending="Revoking…">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look463 Revoke
Merge branch 'worktree-agent-a985c01f43538af01'464 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look465 </Form>
466 </div>
467 <p className="text-xs text-faint">
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)468 By {invite.invitedBy ?? "g1t"} · <TimeAgo at={invite.createdAt} /> · works until{" "}
469 {new Date(invite.expiresAt).toISOString().slice(0, 10)}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look470 </p>
471 {invite.code && <CopyLine text={inviteLink(invite.code, origin)} />}
472 </li>
473 ))}
474 </ul>
475 </section>
476 )}
477
478 <BasePermissionSection
Merge branch 'worktree-agent-a985c01f43538af01'479 // Starts from the saved choice whenever it changes.
480 key={base}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look481 base={base}
482 owner={owner}
483 slug={params.owner}
484 error={actionData && "base" in actionData && "error" in actionData ? (actionData.error ?? null) : null}
485 saved={Boolean(actionData && "based" in actionData)}
486 />
Merge main (membership, two-factor, GitHub repo roles) into tokens487
488 <LeaveSection
489 slug={params.owner}
490 soleOwner={owner && owners <= 1}
491 error={actionData && "leaving" in actionData ? (actionData.error ?? null) : null}
492 />
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look493 </>
494 );
495 return (
496 <div className="max-w-2xl">
497 {owner ? (
498 <Tabs
499 value={tab}
500 onValueChange={(value) => {
501 const next = new URLSearchParams(search);
502 if (value === "outside") next.set("tab", "outside");
503 else next.delete("tab");
504 setSearch(next, { replace: true, preventScrollReset: true });
505 }}
506 >
507 <TabsList className="mb-3">
508 <TabsTrigger value="members">Members · {members.length}</TabsTrigger>
509 <TabsTrigger value="outside">Outside collaborators · {outside.length}</TabsTrigger>
510 </TabsList>
511 <TabsContent value="members">{membersTab}</TabsContent>
512 <TabsContent value="outside">
513 <OutsideCollaborators
514 people={outside}
515 slug={params.owner}
Merge Stripe Tax, the card fee on card payments, and one free workspace per person516 free={free}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look517 error={actionData && "converting" in actionData && actionData.converting ? actionData.error : null}
518 />
519 </TabsContent>
520 </Tabs>
521 ) : (
522 membersTab
523 )}
Agents as a team: lifecycle, merge queue, billing and a new shell524 </div>
525 );
526}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look527
528/** What members get on every repository, which owners choose. */
529function BasePermissionSection({
530 base,
531 owner,
532 slug,
533 error,
534 saved,
535}: {
536 base: BasePermission;
537 owner: boolean;
538 slug: string;
539 error: string | null;
540 saved: boolean;
541}) {
542 const [chosen, setChosen] = useState<BasePermission>(base);
543 return (
544 <section id="base-permission" className="mt-10 scroll-mt-20 border-t border-line pt-8">
545 <h2 className="font-medium">Base permission</h2>
546 <p className="mt-1 text-sm text-muted">
547 The role every member has on each of {slug}'s repositories. Owners are Admins on all of them, and a role given to
548 someone on one repository adds to this; it never takes away.
549 </p>
550 {owner ? (
551 <Form method="post" className="mt-4 rounded-xl border border-line bg-surface p-4">
552 <input type="hidden" name="action" value="base-permission" />
553 <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
554 <Select name="base" value={chosen} onValueChange={(value) => setChosen(value as BasePermission)}>
555 <SelectTrigger aria-label="Base permission" className="sm:max-w-48">
556 <SelectValue />
557 </SelectTrigger>
558 <SelectContent>
559 {BASE_PERMISSIONS.map((value) => (
560 <SelectItem key={value} value={value} description={BASE_MEANS[value]}>
561 {BASE_PERMISSION_LABELS[value]}
562 </SelectItem>
563 ))}
564 </SelectContent>
565 </Select>
Merge branch 'worktree-agent-a985c01f43538af01'566 <SubmitButton variant="quiet" match={{ action: "base-permission" }} pending="Saving…" disabled={chosen === base}>
567 Save
568 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look569 {saved && chosen === base && <span className="text-sm text-muted">Saved.</span>}
570 </div>
571 <p className="mt-3 text-sm text-muted">{BASE_MEANS[chosen]}</p>
572 <ErrorText>{error}</ErrorText>
573 </Form>
574 ) : (
575 <div className="mt-4 rounded-xl border border-line bg-surface p-4 text-sm">
576 <p>
577 <span className="font-medium">{BASE_PERMISSION_LABELS[base]}.</span>{" "}
578 <span className="text-muted">{BASE_MEANS[base]}</span>
579 </p>
580 <p className="mt-2 text-xs text-faint">Owners of the workspace choose it.</p>
581 </div>
582 )}
583 </section>
584 );
585}
586
587/** People with a role on some of the workspace's repositories who are not members of it. */
588function OutsideCollaborators({
589 people,
590 slug,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person591 free,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look592 error,
593}: {
594 people: Route.ComponentProps["loaderData"]["outside"];
595 slug: string;
Merge Stripe Tax, the card fee on card payments, and one free workspace per person596 free: boolean;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look597 error: string | null | undefined;
598}) {
599 return (
600 <>
601 <p className="mb-4 text-sm text-muted">
602 People given a role on one or more of {slug}'s repositories without being members. They see only those
603 repositories. Change or take away a role on the repository's Access settings.
604 </p>
Merge Stripe Tax, the card fee on card payments, and one free workspace per person605 {free && (
606 <div className="mb-4">
607 <StartPlanToInvite workspace={slug} owner />
608 </div>
609 )}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look610 {people.length === 0 ? (
611 <div className="rounded-xl border border-dashed border-line px-6 py-10 text-center">
612 <Users size={18} className="mx-auto text-faint" />
613 <p className="mt-2 text-sm font-medium">No outside collaborators</p>
614 <p className="mt-1 text-sm text-muted">Add someone to a single repository from its Settings → Access.</p>
615 </div>
616 ) : (
617 <ul className="divide-y divide-line rounded-xl border border-line">
618 {people.map((person) => (
619 <li key={person.username} className="px-4 py-3">
620 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
621 <Avatar name={person.username} image={person.avatar} size={28} />
622 <div className="min-w-0 grow basis-32">
Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016)623 <UserCard username={person.username}>
624 <Link to={`/u/${person.username}`} className="font-mono text-sm hover:text-accent">
625 {person.username}
626 </Link>
627 </UserCard>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look628 {person.name && <span className="ml-2 text-sm text-muted">{person.name}</span>}
629 </div>
Merge Stripe Tax, the card fee on card payments, and one free workspace per person630 {!free && (
631 <Form method="post">
632 <input type="hidden" name="action" value="convert" />
633 <input type="hidden" name="member" value={person.username} />
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)634 <SubmitButton variant="quiet" match={{ action: "convert", member: person.username }} pending="Inviting…">
635 Invite as a member
Merge Stripe Tax, the card fee on card payments, and one free workspace per person636 </SubmitButton>
637 </Form>
638 )}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look639 </div>
640 <ul className="mt-2 flex flex-wrap gap-1.5 pl-10">
641 {person.repos.map((grant) => (
642 <li key={grant.repo}>
643 <Link
644 to={`/${grant.repo}/settings/access`}
645 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"
646 >
647 <span className="font-mono text-fg/90">{grant.repo}</span>
648 <span className="text-muted">{REPO_ROLE_LABELS[grant.role]}</span>
649 <ArrowUpRight size={11} className="text-faint group-hover:text-fg" />
650 </Link>
651 </li>
652 ))}
653 </ul>
654 </li>
655 ))}
656 </ul>
657 )}
658 <ErrorText>{error}</ErrorText>
659 </>
660 );
661}

This file's history is long; its oldest lines are credited to the oldest commit read.