Skip to content
685 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";
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request17
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";
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers22import { PersonStatusEmoji, WithPresence } from "../../components/presence";
Merge main (membership, two-factor, GitHub repo roles) into tokens23import {
24 AlertDialog,
25 AlertDialogCancel,
26 AlertDialogContent,
27 AlertDialogDescription,
28 AlertDialogFooter,
29 AlertDialogHeader,
30 AlertDialogTitle,
31} from "../../components/ui/alert-dialog";
32import {
33 DropdownMenu,
34 DropdownMenuContent,
35 DropdownMenuItem,
36 DropdownMenuLabel,
37 DropdownMenuSeparator,
38 DropdownMenuTrigger,
39} from "../../components/ui/dropdown-menu";
40import { Hint } from "../../components/ui/hint";
41import { forgetWorkspace } from "../../lib/workspace-choice";
Chat controls, public profiles, shadcn selects, and no Docs tab in a project42import { Select, SelectContent, SelectField, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look43import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs";
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)44import { inviteLink, inviteState, moreInvitesMailto, workspaceInviteCopy } from "../../lib/invites";
45import { registrationMode } from "../../lib/registration.server";
Merge Stripe Tax, the card fee on card payments, and one free workspace per person46import { billing, identity } from "../../lib/services.server";
47import { StartPlanToInvite } from "../../components/start-plan";
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request48import {
49 assertSameOrigin,
50 getViewer,
51 requireUser,
52 roleIn,
53 unwrap,
54} from "../../lib/session.server";
Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016)55import { 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)56import { PeoplePicker } from "../../components/people-picker";
57import { inviteTarget } from "../../lib/people-search";
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request58
Social cards for every page: og.g1t.sh59export function meta({ params, ...args }: Route.MetaArgs) {
Navigation: the workspace's own page with tabs, pins and recent in the sidebar60 return page(args, { title: `People · ${params.owner} · g1t` });
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request61}
62
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look63export async function loader({ params, context, request }: Route.LoaderArgs) {
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request64 const viewer = getViewer(context);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look65 const role = roleIn(viewer, params.owner);
66 // Who is in a workspace is its members' business; anyone else, including
67 // someone a repository is shared with, gets nothing here.
68 if (!role) throw data(null, { status: 404 });
69 const owner = role === "owner";
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)70 const [members, invites, workspace, outside, teams, free, mode] = await Promise.all([
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look71 identity.listMembers(params.owner, viewer),
72 owner ? identity.workspaceInvites(params.owner, viewer).catch(() => null) : null,
73 identity.getWorkspace(params.owner),
74 owner ? identity.outsideCollaborators(viewer, params.owner).catch(() => null) : null,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar75 // Each member's teams, as the viewer may see them.
76 identity.teamMemberships(viewer, params.owner).catch(() => null),
Merge Stripe Tax, the card fee on card payments, and one free workspace per person77 // A free workspace adds no one until it starts the plan; identity
78 // refuses it either way, so a failure here only hides the note.
79 billing.freeWorkspaces([params.owner]).catch(() => [] as string[]),
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)80 // Whether sign-up takes an invite: the invite form says what that means.
81 registrationMode(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look82 ]);
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request83 return {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look84 role,
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)85 name: workspace?.name?.trim() || params.owner,
86 inviteOnly: mode !== "open",
Merge Stripe Tax, the card fee on card payments, and one free workspace per person87 free: free.includes(params.owner.toLowerCase()),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look88 members: unwrap(members),
89 invites: invites?.ok ? invites.value : [],
90 base: workspace?.basePermission ?? DEFAULT_BASE_PERMISSION,
91 outside: outside?.ok ? outside.value : [],
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar92 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 look93 origin: new URL(request.url).origin,
Merge main (membership, two-factor, GitHub repo roles) into tokens94 me: viewer?.username ?? null,
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request95 };
96}
97
98export async function action({ request, params, context }: Route.ActionArgs) {
99 assertSameOrigin(request);
100 const user = requireUser(context, request);
101 const form = await request.formData();
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look102 if (form.get("action") === "revoke-invite") {
103 const result = await identity.revokeWorkspaceInvite(user, params.owner, String(form.get("id") ?? ""));
104 return result.ok ? null : { error: result.error.message };
105 }
Merge main (membership, two-factor, GitHub repo roles) into tokens106 const target = String(form.get("member") ?? "").trim();
107 // Owner or member, and the roles that add to a member.
108 if (form.get("action") === "role") {
109 const role = form.get("role") === "owner" ? "owner" : "member";
110 const result = await identity.updateMember(user, params.owner, target, { role });
111 return result.ok ? { changed: target } : { error: result.error.message, row: target };
112 }
113 if (form.get("action") === "org-roles") {
114 const roles = String(form.get("org_roles") ?? "")
115 .split(",")
116 .filter((role): role is OrgRole => (ORG_ROLES as readonly string[]).includes(role));
117 const result = await identity.updateMember(user, params.owner, target, { org_roles: roles });
118 return result.ok ? { changed: target } : { error: result.error.message, row: target };
119 }
120 if (form.get("action") === "transfer") {
121 const result = await identity.transferOwnership(user, params.owner, target);
122 return result.ok ? { transferred: target } : { error: result.error.message, row: target };
123 }
124 if (form.get("action") === "leave") {
125 const result = await identity.leaveWorkspace(user, params.owner);
126 if (!result.ok) return { error: result.error.message, leaving: true };
127 const secure = new URL(request.url).protocol === "https:";
128 throw redirect("/", { headers: { "Set-Cookie": forgetWorkspace(secure) } });
129 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look130 if (form.get("action") === "base-permission") {
131 const base = String(form.get("base") ?? "");
132 if (!(BASE_PERMISSIONS as readonly string[]).includes(base)) return { error: "Choose a base permission." };
133 const result = await identity.setBasePermission(user, params.owner, base as BasePermission);
134 return result.ok ? { based: result.value } : { error: result.error.message, base: true };
135 }
136 const member = String(form.get("member") ?? "").trim();
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)137 if (form.get("action") === "remove") {
138 const result = await identity.removeMember(user, params.owner, member);
139 return result.ok ? null : { error: result.error.message, converting: false };
140 }
141 // Nobody joins without saying yes: a username or an address gets an
142 // invitation to accept or decline, with the role chosen here.
143 const who = inviteTarget(member);
144 if (!who) return { error: "Enter a g1t username or an email address.", outOfInvites: false };
145 const role = form.get("role") === "owner" ? "owner" : "member";
146 const result = await identity.inviteMember(user, params.owner, { ...who, role });
147 if (!result.ok) {
148 return { error: result.error.message, outOfInvites: result.error.code === "limit", converting: form.get("action") === "convert" };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look149 }
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)150 if (form.get("action") === "convert") return { converted: member };
151 return { invited: "email" in who ? who.email : `@${"username" in who ? who.username : member}`, outOfInvites: false };
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request152}
153
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look154/** What the base permission means for members, in a sentence. */
155const BASE_MEANS: Record<BasePermission, string> = {
156 none: "Members see only public repositories and the ones they are given a role on.",
157 read: "Members can see and clone every repository, open issues and pull requests, and comment.",
158 write: "Members can see every repository, manage its issues and pull requests, push, merge, and put agents to work.",
159 admin: "Members can do everything on every repository, including its settings, webhooks, secrets and who has access. Only owners transfer or delete one.",
160};
161
Merge main (membership, two-factor, GitHub repo roles) into tokens162/** What a member's roles say about them, as badges: owner or member, then the roles on top. */
163function RoleBadges({ member }: { member: Member }) {
164 return (
165 <span className="flex flex-wrap justify-end gap-1">
166 {member.role === "owner" ? <Badge tone="accent">Owner</Badge> : <Badge>Member</Badge>}
167 {(member.org_roles ?? []).map((role) => (
168 <Hint key={role} label={ORG_ROLE_SUMMARIES[role]}>
169 <Badge tone="info" tabIndex={0}>
170 {ORG_ROLE_LABELS[role]}
171 </Badge>
172 </Hint>
173 ))}
174 </span>
175 );
176}
177
178/** Whether a member has two-factor authentication on, for owners. */
179function TwoFactorMark({ on }: { on: boolean | null | undefined }) {
180 if (on == null) return null;
181 return on ? (
182 <Hint label="Two-factor authentication is on">
183 <span tabIndex={0} className="inline-flex text-success" aria-label="Two-factor authentication on">
184 <ShieldCheck size={15} />
185 </span>
186 </Hint>
187 ) : (
188 <Hint label="Two-factor authentication is off">
189 <span tabIndex={0} className="inline-flex text-warn" aria-label="Two-factor authentication off">
190 <ShieldAlert size={15} />
191 </span>
192 </Hint>
193 );
194}
195
196/** An owner's menu for one member: their role, the roles on top, handing over and removing. */
197function MemberMenu({ member, self, owners, slug }: { member: Member; self: boolean; owners: number; slug: string }) {
198 const submit = useSubmit();
199 const [confirm, setConfirm] = useState<"transfer" | "remove" | null>(null);
200 const roles = member.org_roles ?? [];
201 const post = (fields: Record<string, string>) =>
202 submit({ ...fields, member: member.username }, { method: "post", preventScrollReset: true });
203 const lastOwner = member.role === "owner" && owners <= 1;
204 return (
205 <>
206 <DropdownMenu>
207 <DropdownMenuTrigger asChild>
208 <button
209 type="button"
210 aria-label={`Manage ${member.username}`}
Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar211 className="rounded-md p-1.5 text-faint transition-colors max-sm:p-2.5 hover:bg-raised hover:text-fg"
Merge main (membership, two-factor, GitHub repo roles) into tokens212 >
213 <Ellipsis size={16} />
214 </button>
215 </DropdownMenuTrigger>
216 <DropdownMenuContent align="end" className="w-64">
217 <DropdownMenuLabel>Role in {slug}</DropdownMenuLabel>
218 {member.role === "member" ? (
219 <DropdownMenuItem onSelect={() => post({ action: "role", role: "owner" })}>Make owner</DropdownMenuItem>
220 ) : (
221 <DropdownMenuItem disabled={lastOwner} onSelect={() => post({ action: "role", role: "member" })}>
222 {lastOwner ? "The only owner" : "Make member"}
223 </DropdownMenuItem>
224 )}
225 <DropdownMenuSeparator />
226 <DropdownMenuLabel>Also</DropdownMenuLabel>
227 {ORG_ROLES.map((role) => {
228 const has = roles.includes(role);
229 const next = has ? roles.filter((other) => other !== role) : [...roles, role];
230 return (
231 <DropdownMenuItem key={role} onSelect={() => post({ action: "org-roles", org_roles: next.join(",") })}>
232 <span className="flex size-4 items-center justify-center">{has && <Check />}</span>
233 {ORG_ROLE_LABELS[role]}
234 </DropdownMenuItem>
235 );
236 })}
237 {!self && (
238 <>
239 <DropdownMenuSeparator />
240 {member.role === "member" && (
241 <DropdownMenuItem onSelect={() => setConfirm("transfer")}>Transfer ownership…</DropdownMenuItem>
242 )}
243 <DropdownMenuItem disabled={lastOwner} onSelect={() => setConfirm("remove")} className="text-danger">
244 Remove from {slug}…
245 </DropdownMenuItem>
246 </>
247 )}
248 </DropdownMenuContent>
249 </DropdownMenu>
250 <AlertDialog open={confirm !== null} onOpenChange={(open) => !open && setConfirm(null)}>
251 <AlertDialogContent>
252 <Form method="post" className="grid gap-4" onSubmit={() => setConfirm(null)}>
253 <input type="hidden" name="action" value={confirm === "transfer" ? "transfer" : "remove"} />
254 <input type="hidden" name="member" value={member.username} />
255 <AlertDialogHeader>
256 <AlertDialogTitle>
257 {confirm === "transfer" ? `Hand ${slug} to ${member.username}?` : `Remove ${member.username} from ${slug}?`}
258 </AlertDialogTitle>
259 <AlertDialogDescription>
260 {confirm === "transfer"
261 ? `${member.username} becomes an owner and you a member. Only an owner can make you an owner again.`
262 : `Their roles on ${slug}'s repositories and their place in its teams go too. To keep them on a repository, add them back to it as an outside collaborator.`}
263 </AlertDialogDescription>
264 </AlertDialogHeader>
265 <AlertDialogFooter>
266 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
267 <SubmitButton variant="danger" match={{ action: confirm ?? "", member: member.username }} pending="Working…">
268 {confirm === "transfer" ? "Transfer ownership" : "Remove"}
269 </SubmitButton>
270 </AlertDialogFooter>
271 </Form>
272 </AlertDialogContent>
273 </AlertDialog>
274 </>
275 );
276}
277
278/** Leaving the workspace, for anyone in it. */
279function LeaveSection({ slug, soleOwner, error }: { slug: string; soleOwner: boolean; error: string | null }) {
280 const [open, setOpen] = useState(false);
281 return (
282 <section id="leave" className="mt-10 scroll-mt-20 border-t border-line pt-8">
283 <h2 className="font-medium">Leave {slug}</h2>
284 <p className="mt-1 text-sm text-muted">
285 {soleOwner
286 ? `You are ${slug}'s only owner. Make another member an owner first, or delete the workspace in its settings.`
287 : "Your roles on its repositories and your place in its teams go too. An owner can add you again."}
288 </p>
289 <div className="mt-4">
290 <Button variant="danger" type="button" disabled={soleOwner} onClick={() => setOpen(true)}>
291 Leave {slug}
292 </Button>
293 </div>
294 <ErrorText>{error}</ErrorText>
295 <AlertDialog open={open} onOpenChange={setOpen}>
296 <AlertDialogContent>
297 <Form method="post" className="grid gap-4">
298 <input type="hidden" name="action" value="leave" />
299 <AlertDialogHeader>
300 <AlertDialogTitle>Leave {slug}?</AlertDialogTitle>
301 <AlertDialogDescription>You lose access to its private repositories at once.</AlertDialogDescription>
302 </AlertDialogHeader>
303 <AlertDialogFooter>
304 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
305 <SubmitButton variant="danger" match={{ action: "leave" }} pending="Leaving…">
306 Leave
307 </SubmitButton>
308 </AlertDialogFooter>
309 </Form>
310 </AlertDialogContent>
311 </AlertDialog>
312 </section>
313 );
314}
315
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look316export default function WorkspacePeople({ loaderData, actionData, params }: Route.ComponentProps) {
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)317 const { role, members, invites, origin, base, outside, teams, free, me, name, inviteOnly } = loaderData;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look318 const owner = role === "owner";
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)319 // The invitation to join this workspace, not an invite to g1t (Settings → Invites).
320 const inviteCopy = workspaceInviteCopy(name, inviteOnly);
Merge main (membership, two-factor, GitHub repo roles) into tokens321 const owners = members.filter((member) => member.role === "owner").length;
322 const rowError = (username: string) =>
323 actionData && "row" in actionData && actionData.row === username ? (actionData.error ?? null) : null;
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)324 // Waiting to be used, or used by someone still confirming their email:
325 // 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)326 // Waiting for an answer to a workspace invitation, too.
327 const pending = invites.filter(
328 (invite) => invite.status === "pending" || invite.status === "awaiting_confirmation" || invite.status === "awaiting_answer",
329 );
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look330 const [search, setSearch] = useSearchParams();
331 const tab = search.get("tab") === "outside" && owner ? "outside" : "members";
332 const membersTab = (
333 <>
A catalogue of model providers, and settings that feel like settings334 <ul className="divide-y divide-line rounded-xl border border-line">
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request335 {members.map((member) => (
Merge main (membership, two-factor, GitHub repo roles) into tokens336 <li key={member.username} className="flex flex-wrap items-center gap-3 px-4 py-3">
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers337 <WithPresence person={{ username: member.username }} size={28}>
338 <Avatar name={member.username} image={member.avatar} size={28} />
339 </WithPresence>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look340 <div className="min-w-0 grow truncate">
Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016)341 <UserCard username={member.username}>
342 <Link to={`/u/${member.username}`} className="font-mono text-sm hover:text-accent">
343 {member.username}
344 </Link>
345 </UserCard>
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers346 <PersonStatusEmoji person={{ username: member.username }} size={13} className="ml-1.5 align-[-2px]" />
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look347 {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 bar348 {(teams[member.username] ?? []).length > 0 && (
349 <div className="mt-1 flex flex-wrap gap-1">
350 {(teams[member.username] ?? []).slice(0, 3).map((team) => (
351 <Link
352 key={team.slug}
353 to={`/${params.owner}/-/teams/${team.slug}`}
354 className="rounded-full border border-line px-2 py-px text-xs text-muted transition-colors hover:border-line-strong hover:text-fg"
355 >
356 {team.name}
357 </Link>
358 ))}
359 {(teams[member.username] ?? []).length > 3 && (
360 <span className="px-1 text-xs text-faint">+{(teams[member.username] ?? []).length - 3} more</span>
361 )}
362 </div>
363 )}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look364 </div>
Merge main (membership, two-factor, GitHub repo roles) into tokens365 {/* Together, so on a narrow screen they move under the name as one. */}
366 <div className="ml-auto flex items-center gap-2">
367 {owner && <TwoFactorMark on={member.two_factor} />}
368 <RoleBadges member={member} />
369 {owner && <MemberMenu member={member} self={member.username === me} owners={owners} slug={params.owner} />}
370 </div>
371 {rowError(member.username) && (
372 <p className="basis-full text-sm text-danger" role="alert">
373 {rowError(member.username)}
374 </p>
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request375 )}
376 </li>
377 ))}
378 </ul>
Merge Stripe Tax, the card fee on card payments, and one free workspace per person379 {owner && free && (
380 <div className="mt-6">
381 <StartPlanToInvite workspace={params.owner} owner={owner} />
382 </div>
383 )}
384 {owner && !free && (
Merge branch 'worktree-agent-a985c01f43538af01'385 // Empty again once the person is on the list; kept as typed when it failed.
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)386 <section aria-labelledby="invite-to-workspace" className="mt-6">
387 <h2 id="invite-to-workspace" className="text-sm font-medium">
388 {inviteCopy.heading}
389 </h2>
390 <p className="mt-1 text-xs text-faint">{inviteCopy.hint}</p>
Merge branch 'worktree-agent-a985c01f43538af01'391 <Form
392 method="post"
393 key={`${members.length}:${pending.length}`}
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)394 className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-start"
Merge branch 'worktree-agent-a985c01f43538af01'395 >
396 <input type="hidden" name="action" value="add" />
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request397 <div className="grow">
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)398 <Field label="Who">
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)399 <PeoplePicker name="member" placeholder="username, name or name@example.com" />
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request400 </Field>
401 </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)402 <label className="block">
403 <span className="mb-1.5 block text-sm font-medium text-muted">Role</span>
Chat controls, public profiles, shadcn selects, and no Docs tab in a project404 <SelectField
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)405 name="role"
406 defaultValue="member"
Chat controls, public profiles, shadcn selects, and no Docs tab in a project407 className="h-auto w-full py-2 sm:w-auto sm:min-w-32"
408 options={[
409 { value: "member", label: "Member" },
410 { value: "owner", label: "Owner" },
411 ]}
412 />
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)413 </label>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look414 <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)415 <SubmitButton match={{ action: "add" }} pending="Inviting…">
416 Invite
Merge branch 'worktree-agent-a985c01f43538af01'417 </SubmitButton>
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request418 </div>
419 </Form>
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)420 {inviteCopy.elsewhere && (
421 <p className="mt-2 text-xs text-faint">
422 {inviteCopy.elsewhere.replace(/Settings → Invites\.$/, "")}
423 <Link to="/settings/invites" className="text-muted underline underline-offset-4 hover:text-fg">
424 Settings → Invites
425 </Link>
426 .
427 </p>
428 )}
429 </section>
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request430 )}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look431 {actionData && "invited" in actionData && actionData.invited && (
432 <p className="text-sm text-muted" role="status">
Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page)433 Invitation to join {name} 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 look434 </p>
435 )}
Merge main (membership, two-factor, GitHub repo roles) into tokens436 {actionData && "transferred" in actionData && (
437 <p className="text-sm text-muted" role="status">
438 <span className="font-mono text-fg">{actionData.transferred}</span> owns {params.owner} now, and you are a member.
439 </p>
440 )}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look441 {actionData && "converted" in actionData && (
442 <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)443 <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 look444 </p>
445 )}
Merge main (membership, two-factor, GitHub repo roles) into tokens446 {actionData &&
447 !("base" in actionData) &&
448 !("row" in actionData) &&
449 !("leaving" in actionData) &&
450 !("converting" in actionData && actionData.converting) && (
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look451 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
452 )}
453 {actionData && "outOfInvites" in actionData && actionData.outOfInvites && (
454 <p className="mt-1 text-sm text-muted">
455 <a href={moreInvitesMailto(params.owner)} className="text-accent underline underline-offset-4">
456 Ask for more invites
457 </a>{" "}
458 for {params.owner}.
459 </p>
460 )}
461
462 {owner && pending.length > 0 && (
463 <section className="mt-8">
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)464 <h2 className="text-sm font-medium">Pending invitations</h2>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look465 <ul className="mt-3 divide-y divide-line rounded-xl border border-line">
466 {pending.map((invite) => (
467 <li key={invite.id} className="space-y-2 px-4 py-3">
468 <div className="flex flex-wrap items-center gap-3">
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)469 <span className="min-w-0 grow truncate text-sm">
470 {invite.email ??
471 (invite.invitee ? (
472 <UserCard username={invite.invitee}>
473 <Link to={`/u/${invite.invitee}`} className="font-mono hover:text-accent">
474 @{invite.invitee}
475 </Link>
476 </UserCard>
477 ) : (
478 "Anyone with the link"
479 ))}
480 </span>
481 {invite.role === "owner" && <Pill>Owner</Pill>}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look482 <Pill>{inviteState(invite).label}</Pill>
483 <Form method="post">
484 <input type="hidden" name="action" value="revoke-invite" />
485 <input type="hidden" name="id" value={invite.id} />
Merge branch 'worktree-agent-a985c01f43538af01'486 <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 look487 Revoke
Merge branch 'worktree-agent-a985c01f43538af01'488 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look489 </Form>
490 </div>
491 <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)492 By {invite.invitedBy ?? "g1t"} · <TimeAgo at={invite.createdAt} /> · works until{" "}
493 {new Date(invite.expiresAt).toISOString().slice(0, 10)}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look494 </p>
495 {invite.code && <CopyLine text={inviteLink(invite.code, origin)} />}
496 </li>
497 ))}
498 </ul>
499 </section>
500 )}
501
502 <BasePermissionSection
Merge branch 'worktree-agent-a985c01f43538af01'503 // Starts from the saved choice whenever it changes.
504 key={base}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look505 base={base}
506 owner={owner}
507 slug={params.owner}
508 error={actionData && "base" in actionData && "error" in actionData ? (actionData.error ?? null) : null}
509 saved={Boolean(actionData && "based" in actionData)}
510 />
Merge main (membership, two-factor, GitHub repo roles) into tokens511
512 <LeaveSection
513 slug={params.owner}
514 soleOwner={owner && owners <= 1}
515 error={actionData && "leaving" in actionData ? (actionData.error ?? null) : null}
516 />
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look517 </>
518 );
519 return (
520 <div className="max-w-2xl">
521 {owner ? (
522 <Tabs
523 value={tab}
524 onValueChange={(value) => {
525 const next = new URLSearchParams(search);
526 if (value === "outside") next.set("tab", "outside");
527 else next.delete("tab");
528 setSearch(next, { replace: true, preventScrollReset: true });
529 }}
530 >
531 <TabsList className="mb-3">
532 <TabsTrigger value="members">Members · {members.length}</TabsTrigger>
533 <TabsTrigger value="outside">Outside collaborators · {outside.length}</TabsTrigger>
534 </TabsList>
535 <TabsContent value="members">{membersTab}</TabsContent>
536 <TabsContent value="outside">
537 <OutsideCollaborators
538 people={outside}
539 slug={params.owner}
Merge Stripe Tax, the card fee on card payments, and one free workspace per person540 free={free}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look541 error={actionData && "converting" in actionData && actionData.converting ? actionData.error : null}
542 />
543 </TabsContent>
544 </Tabs>
545 ) : (
546 membersTab
547 )}
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request548 </div>
549 );
550}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look551
552/** What members get on every repository, which owners choose. */
553function BasePermissionSection({
554 base,
555 owner,
556 slug,
557 error,
558 saved,
559}: {
560 base: BasePermission;
561 owner: boolean;
562 slug: string;
563 error: string | null;
564 saved: boolean;
565}) {
566 const [chosen, setChosen] = useState<BasePermission>(base);
567 return (
568 <section id="base-permission" className="mt-10 scroll-mt-20 border-t border-line pt-8">
569 <h2 className="font-medium">Base permission</h2>
570 <p className="mt-1 text-sm text-muted">
571 The role every member has on each of {slug}'s repositories. Owners are Admins on all of them, and a role given to
572 someone on one repository adds to this; it never takes away.
573 </p>
574 {owner ? (
575 <Form method="post" className="mt-4 rounded-xl border border-line bg-surface p-4">
576 <input type="hidden" name="action" value="base-permission" />
577 <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
578 <Select name="base" value={chosen} onValueChange={(value) => setChosen(value as BasePermission)}>
579 <SelectTrigger aria-label="Base permission" className="sm:max-w-48">
580 <SelectValue />
581 </SelectTrigger>
582 <SelectContent>
583 {BASE_PERMISSIONS.map((value) => (
584 <SelectItem key={value} value={value} description={BASE_MEANS[value]}>
585 {BASE_PERMISSION_LABELS[value]}
586 </SelectItem>
587 ))}
588 </SelectContent>
589 </Select>
Merge branch 'worktree-agent-a985c01f43538af01'590 <SubmitButton variant="quiet" match={{ action: "base-permission" }} pending="Saving…" disabled={chosen === base}>
591 Save
592 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look593 {saved && chosen === base && <span className="text-sm text-muted">Saved.</span>}
594 </div>
595 <p className="mt-3 text-sm text-muted">{BASE_MEANS[chosen]}</p>
596 <ErrorText>{error}</ErrorText>
597 </Form>
598 ) : (
599 <div className="mt-4 rounded-xl border border-line bg-surface p-4 text-sm">
600 <p>
601 <span className="font-medium">{BASE_PERMISSION_LABELS[base]}.</span>{" "}
602 <span className="text-muted">{BASE_MEANS[base]}</span>
603 </p>
604 <p className="mt-2 text-xs text-faint">Owners of the workspace choose it.</p>
605 </div>
606 )}
607 </section>
608 );
609}
610
611/** People with a role on some of the workspace's repositories who are not members of it. */
612function OutsideCollaborators({
613 people,
614 slug,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person615 free,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look616 error,
617}: {
618 people: Route.ComponentProps["loaderData"]["outside"];
619 slug: string;
Merge Stripe Tax, the card fee on card payments, and one free workspace per person620 free: boolean;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look621 error: string | null | undefined;
622}) {
623 return (
624 <>
625 <p className="mb-4 text-sm text-muted">
626 People given a role on one or more of {slug}'s repositories without being members. They see only those
627 repositories. Change or take away a role on the repository's Access settings.
628 </p>
Merge Stripe Tax, the card fee on card payments, and one free workspace per person629 {free && (
630 <div className="mb-4">
631 <StartPlanToInvite workspace={slug} owner />
632 </div>
633 )}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look634 {people.length === 0 ? (
635 <div className="rounded-xl border border-dashed border-line px-6 py-10 text-center">
636 <Users size={18} className="mx-auto text-faint" />
637 <p className="mt-2 text-sm font-medium">No outside collaborators</p>
638 <p className="mt-1 text-sm text-muted">Add someone to a single repository from its Settings → Access.</p>
639 </div>
640 ) : (
641 <ul className="divide-y divide-line rounded-xl border border-line">
642 {people.map((person) => (
643 <li key={person.username} className="px-4 py-3">
644 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
645 <Avatar name={person.username} image={person.avatar} size={28} />
646 <div className="min-w-0 grow basis-32">
Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016)647 <UserCard username={person.username}>
648 <Link to={`/u/${person.username}`} className="font-mono text-sm hover:text-accent">
649 {person.username}
650 </Link>
651 </UserCard>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look652 {person.name && <span className="ml-2 text-sm text-muted">{person.name}</span>}
653 </div>
Merge Stripe Tax, the card fee on card payments, and one free workspace per person654 {!free && (
655 <Form method="post">
656 <input type="hidden" name="action" value="convert" />
657 <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)658 <SubmitButton variant="quiet" match={{ action: "convert", member: person.username }} pending="Inviting…">
659 Invite as a member
Merge Stripe Tax, the card fee on card payments, and one free workspace per person660 </SubmitButton>
661 </Form>
662 )}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look663 </div>
664 <ul className="mt-2 flex flex-wrap gap-1.5 pl-10">
665 {person.repos.map((grant) => (
666 <li key={grant.repo}>
667 <Link
668 to={`/${grant.repo}/settings/access`}
669 className="group inline-flex items-center gap-1.5 rounded-full border border-line px-2 py-0.5 text-xs transition-colors hover:border-line-strong hover:bg-surface"
670 >
671 <span className="font-mono text-fg/90">{grant.repo}</span>
672 <span className="text-muted">{REPO_ROLE_LABELS[grant.role]}</span>
673 <ArrowUpRight size={11} className="text-faint group-hover:text-fg" />
674 </Link>
675 </li>
676 ))}
677 </ul>
678 </li>
679 ))}
680 </ul>
681 )}
682 <ErrorText>{error}</ErrorText>
683 </>
684 );
685}

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