Skip to content
265 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.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1import { Mail } from "lucide-react";
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)2import { useState } from "react";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look3import { Form, Link } from "react-router";
4
5import type { Invite, InvitesOverview } from "@g1t/contracts";
6
Merge branch 'worktree-agent-a985c01f43538af01'7import { CopyLine, ErrorText, Field, Input, SubmitButton, TimeAgo } from "./ui";
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.8import { Badge } from "./ui/badge";
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)9import { CheckboxOption } from "./ui/checkbox";
10import { Hint } from "./ui/hint";
Chat controls, public profiles, shadcn selects, and no Docs tab in a project11import { SelectField } from "./ui/select";
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)12import { G1T_INVITES, inviteFor, inviteKind, inviteLink, invitesPage, inviteState, moreInvitesMailto, remainingLine } from "../lib/invites";
13import type { BringIntoChoices } from "../lib/invites.server";
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)14
Chat controls, public profiles, shadcn selects, and no Docs tab in a project15const SELECT = "h-auto w-full py-2 sm:w-auto sm:min-w-40";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look16
17const TONE: Record<"pending" | "done" | "dead", string> = {
18 pending: "border-accent/40 text-accent",
19 done: "border-merged/40 text-merged",
20 dead: "border-line text-faint",
21};
22
23function InviteRow({ invite, origin }: { invite: Invite; origin: string }) {
24 const state = inviteState(invite);
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)25 const kind = inviteKind(invite);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look26 return (
27 <li className="space-y-2 px-4 py-3">
28 <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
29 <span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs ${TONE[state.tone]}`}>{state.label}</span>
30 <span className="min-w-0 truncate text-sm">{inviteFor(invite)}</span>
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)31 <Hint
32 label={
33 kind.kind === "g1t"
34 ? "Lets them make an account. It adds them to no workspace."
35 : "An invitation to join this workspace, which they accept or decline. Without an account, it lets them make one first."
36 }
37 >
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.38 <Badge size="md">{kind.label}</Badge>
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)39 </Hint>
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)40 {(invite.status === "pending" || invite.status === "awaiting_confirmation" || invite.status === "awaiting_answer") && (
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look41 <Form method="post" className="ml-auto">
42 <input type="hidden" name="intent" value="revoke-invite" />
43 <input type="hidden" name="id" value={invite.id} />
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.44 <SubmitButton variant="outline" match={{ intent: "revoke-invite", id: invite.id }} pending="Revoking…">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look45 Revoke
Merge branch 'worktree-agent-a985c01f43538af01'46 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look47 </Form>
48 )}
49 </div>
50 <p className="text-xs text-faint">
51 <span className="font-mono">{invite.hint}…</span> · made <TimeAgo at={invite.createdAt} />
52 {invite.status === "pending" && (
53 <>
54 {" "}· works until {new Date(invite.expiresAt).toISOString().slice(0, 10)}
55 </>
56 )}
57 {invite.chargedTo === "workspace" && invite.workspace == null && " · from a workspace's invites"}
58 </p>
59 {invite.status === "pending" && invite.code && <CopyLine text={inviteLink(invite.code, origin)} />}
60 </li>
61 );
62}
63
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)64/** "To bring someone into a workspace, invite them from that workspace's People page", with those pages. */
65function Elsewhere({ people }: { people: BringIntoChoices["people"] }) {
66 return (
67 <p className="mt-4 text-sm text-muted">
68 {G1T_INVITES.elsewhere}
69 {people.length > 0 ? ": " : "."}
70 {people.map((workspace, index) => (
71 <span key={workspace.slug}>
72 {index > 0 && ", "}
73 <Link to={workspace.to} className="text-fg underline underline-offset-4 hover:text-accent">
74 {workspace.name}
75 </Link>
76 </span>
77 ))}
78 {people.length > 0 && "."}
79 </p>
80 );
81}
82
83/**
84 * Making an invite to g1t: an address (or anyone with the link), whose
85 * invites it uses, and, only when its box is ticked, a workspace to invite
86 * them to as well. Unticked, nothing about a workspace is sent.
87 */
88function InviteForm({ overview, bringInto }: { overview: InvitesOverview; bringInto: BringIntoChoices }) {
89 const [alsoJoin, setAlsoJoin] = useState(false);
90 const { workspaces } = overview;
91 return (
92 <Form method="post" className="mt-4 space-y-4">
93 <input type="hidden" name="intent" value="create-invite" />
94 <div className="flex flex-col gap-3 sm:flex-row sm:items-end">
95 <div className="grow">
96 <Field label="Email (optional)">
97 <Input name="email" type="email" maxLength={254} placeholder="Anyone with the link" />
98 </Field>
99 </div>
100 {workspaces.length > 0 && (
101 <label className="block">
102 <span className="mb-1.5 block text-sm font-medium text-muted">Use</span>
Chat controls, public profiles, shadcn selects, and no Docs tab in a project103 <SelectField
104 name="charge"
105 defaultValue="mine"
106 className={SELECT}
107 options={[
108 { value: "mine", label: "Your invites" },
109 ...workspaces
110 .filter((workspace) => workspace.allowance.remaining !== 0)
111 .map((workspace) => ({ value: workspace.slug, label: `${workspace.slug}'s invites` })),
112 ]}
113 />
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)114 </label>
115 )}
116 <SubmitButton match={{ intent: "create-invite" }} pending="Creating…">
117 Create invite
118 </SubmitButton>
119 </div>
120 <p className="text-xs text-faint">With an email, the invite is sent there and only that address can use it.</p>
121
122 {bringInto.options.length > 0 && (
123 <div className="space-y-3 rounded-md border border-line p-3">
124 <CheckboxOption
125 name="also_join"
126 checked={alsoJoin}
127 onCheckedChange={(checked) => setAlsoJoin(checked === true)}
128 label={G1T_INVITES.alsoJoin}
129 description={G1T_INVITES.alsoJoinHint}
130 />
131 {alsoJoin && (
132 <div className="flex flex-col gap-3 pl-6 sm:flex-row sm:items-end">
133 <label className="block">
134 <span className="mb-1.5 block text-sm font-medium text-muted">Workspace</span>
Chat controls, public profiles, shadcn selects, and no Docs tab in a project135 <SelectField
136 name="join"
137 required
138 placeholder="Choose a workspace"
139 className={SELECT}
140 options={bringInto.options.map((workspace) => ({
141 value: workspace.slug,
142 label: workspace.name === workspace.slug ? workspace.slug : `${workspace.name} (${workspace.slug})`,
143 }))}
144 />
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)145 </label>
146 <label className="block">
147 <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 project148 <SelectField
149 name="join_role"
150 defaultValue="member"
151 className={SELECT}
152 options={[
153 { value: "member", label: "Member" },
154 { value: "owner", label: "Owner" },
155 ]}
156 />
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)157 </label>
158 </div>
159 )}
160 {alsoJoin && bringInto.note && <p className="pl-6 text-xs text-faint">{bringInto.note}</p>}
161 </div>
162 )}
163 </Form>
164 );
165}
166
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look167/**
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)168 * Settings → Invites (`/settings/invites`): invites to g1t. Each lets one
169 * person make an account and adds them to no workspace, unless "Also
170 * invite them to a workspace" is ticked. They exist only while sign-up
171 * takes an invite; once anyone can sign up, the page keeps only the list
172 * of invites already made. An invitation to a workspace is made on its
173 * People page, which this page points to.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look174 */
175export function InvitesSection({
176 overview,
177 created,
178 error,
179 origin = "https://g1t.sh",
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)180 bringInto = { options: [], note: null, people: [] },
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look181}: {
182 overview: InvitesOverview | null;
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)183 /** The workspaces an invite can also invite its person to, and the People pages (lib/invites.server.ts). */
184 bringInto?: BringIntoChoices;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look185 created?: Invite;
186 error?: string;
187 origin?: string;
188}) {
189 if (!overview) {
190 return (
191 <section id="invites" className="scroll-mt-20">
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas192 <p className="text-sm text-muted">Confirm your email address to invite people to g1t.</p>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look193 </section>
194 );
195 }
196 const { allowance, workspaces, invites } = overview;
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)197 const shows = invitesPage(overview.mode, invites.length);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look198 const out = allowance.remaining === 0 && workspaces.every((workspace) => workspace.allowance.remaining === 0);
199 return (
200 <section id="invites" className="scroll-mt-20">
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)201 {shows.form ? (
202 <>
203 <p className="text-sm text-muted">A revoked or expired invite that was never used comes back to you.</p>
204 <p className="mt-3 text-sm">
205 <span className="font-medium">{remainingLine(allowance)}</span>
206 {workspaces.map((workspace) => (
207 <span key={workspace.slug} className="text-muted">
208 {" "}· {workspace.slug} has {workspace.allowance.remaining ?? "unlimited"} to share
209 </span>
210 ))}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look211 </p>
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)212
213 {created?.code && (
214 <div className="mt-4 rounded-md border border-accent/40 bg-surface p-4">
215 <p className="text-sm">
216 {created.email ? `Invite sent to ${created.email}. ` : ""}Share this link; it works once
217 {created.email ? ", for that address only" : ""}.
218 </p>
219 <div className="mt-2">
220 <CopyLine text={inviteLink(created.code, origin)} />
221 </div>
222 </div>
223 )}
224
225 {out ? (
226 <div className="mt-4 flex items-start gap-3 rounded-md border border-line bg-surface p-4 text-sm">
227 <Mail size={16} className="mt-0.5 shrink-0 text-muted" />
228 <p>
229 Need more invites?{" "}
230 <a href={moreInvitesMailto()} className="text-accent underline underline-offset-4">
231 Contact us
232 </a>{" "}
233 and say who you would like to bring, or ask on the{" "}
234 <Link to="/support" className="underline underline-offset-4">
235 support page
236 </Link>
237 .
238 </p>
239 </div>
240 ) : (
241 // Empty again once the invite is made; kept as filled in when it failed.
242 <InviteForm key={created?.id ?? ""} overview={overview} bringInto={bringInto} />
243 )}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look244 <div className="mt-2">
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)245 <ErrorText>{error}</ErrorText>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look246 </div>
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)247 <Elsewhere people={bringInto.people} />
248 </>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look249 ) : (
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)250 <p className="text-sm text-muted">{G1T_INVITES.open}</p>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look251 )}
252
253 {invites.length > 0 && (
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)254 <>
255 {!shows.form && <h2 className="mt-6 text-sm font-medium">Invites you made</h2>}
256 <ul className="mt-4 divide-y divide-line rounded-md border border-line">
257 {invites.map((invite) => (
258 <InviteRow key={invite.id} invite={invite} origin={origin} />
259 ))}
260 </ul>
261 </>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look262 )}
263 </section>
264 );
265}

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