Skip to content
167 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 Stripe Tax, the card fee on card payments, and one free workspace per person1import { Form, Link, redirect } from "react-router";
Agents as a team: lifecycle, merge queue, billing and a new shell2
3import type { Route } from "./+types/new";
Social cards for every page: og.g1t.sh4import { page } from "../../lib/meta";
Merge Stripe Tax, the card fee on card payments, and one free workspace per person5import { planHref } from "../../components/start-plan";
6import { ButtonLink, ErrorText, Field, Input, SubmitButton } from "../../components/ui";
7import { billing, identity } from "../../lib/services.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)8import { InvitationList } from "../../components/invitation-list";
9import { answerInvitation, loadInvitations } from "../../lib/invitations.server";
10import { declinedLine } from "../../lib/invitations";
Agents as a team: lifecycle, merge queue, billing and a new shell11import { assertSameOrigin, nextPath, requireUser } from "../../lib/session.server";
12
Social cards for every page: og.g1t.sh13export function meta(args: Route.MetaArgs) {
14 return page(args, { title: "New workspace · g1t" });
Agents as a team: lifecycle, merge queue, billing and a new shell15}
16
Merge Stripe Tax, the card fee on card payments, and one free workspace per person17export async function loader({ request, context }: Route.LoaderArgs) {
Agents as a team: lifecycle, merge queue, billing and a new shell18 const user = requireUser(context, request);
Merge Stripe Tax, the card fee on card payments, and one free workspace per person19 const owned = (user.workspaces ?? []).filter((membership) => membership.role === "owner").map((membership) => membership.slug);
20 // A person owns at most one free workspace; identity refuses a second
21 // either way, so a failure here only leaves the form up.
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)22 const first = (user.workspaces ?? []).length === 0;
23 const [free, invitations] = await Promise.all([
24 owned.length > 0 ? billing.freeWorkspaces(owned).catch(() => [] as string[]) : Promise.resolve([] as string[]),
25 // Someone without a workspace may have been invited to one.
26 first ? loadInvitations(user) : Promise.resolve([]),
27 ]);
28 const next = nextPath(request);
29 return { user, first, free, invitations, next: next === "/" ? null : next };
Agents as a team: lifecycle, merge queue, billing and a new shell30}
31
32export async function action({ request, context }: Route.ActionArgs) {
33 assertSameOrigin(request);
34 const user = requireUser(context, request);
35 const form = await request.formData();
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)36 // Accepting or declining an invitation shown here (lib/invitations.server.ts).
37 const answered = await answerInvitation(user, form, request);
38 if (answered) return answered;
Agents as a team: lifecycle, merge queue, billing and a new shell39 const result = await identity.createWorkspace(
40 user,
41 String(form.get("slug") ?? ""),
42 String(form.get("displayName") ?? ""),
43 );
44 if (!result.ok) return { error: result.error.message };
45 // Someone sent here on their way elsewhere carries on to it.
46 const next = nextPath(request);
47 throw redirect(next === "/" ? `/${result.value.slug}` : next);
48}
49
50export default function NewWorkspace({
51 loaderData,
52 actionData,
53}: Route.ComponentProps) {
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)54 const { user, first, free, invitations, next } = loaderData;
55 const said = actionData as { error?: string; invitationError?: string; invitationId?: string; declined?: string } | undefined;
Agents as a team: lifecycle, merge queue, billing and a new shell56 return (
57 <main className="mx-auto max-w-lg px-4 py-12">
58 <h1 className="text-xl font-semibold">
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)59 {first ? "Create your workspace or ask to join one" : "New workspace"}
Agents as a team: lifecycle, merge queue, billing and a new shell60 </h1>
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)61 {first && (
62 <section className="mt-4">
63 {said?.declined !== undefined && (
64 <p className="mb-3 text-sm text-muted" role="status">
65 {declinedLine(said.declined)}
66 </p>
67 )}
68 {invitations.length > 0 ? (
69 <>
70 <h2 className="text-sm font-medium">You are invited</h2>
71 <p className="mt-1 mb-3 text-sm text-muted">Accept an invitation to join that workspace, or decline it.</p>
72 <InvitationList invitations={invitations} error={said?.invitationError} errorFor={said?.invitationId} next={next} />
73 </>
74 ) : (
75 <p className="text-sm text-muted">
76 To join a team that is on g1t already, ask one of its workspace's owners to invite you by your username,{" "}
77 <span className="font-mono text-fg">{user.username}</span>. Their invitation shows here and in your inbox.
78 </p>
79 )}
80 <h2 className="mt-8 text-sm font-medium">Or create your own</h2>
81 </section>
82 )}
Agents as a team: lifecycle, merge queue, billing and a new shell83 <p className="mt-2 text-sm text-muted">
84 {first && "Everything on g1t lives in a workspace, so this comes first. "}
85 A workspace holds repositories, the people who work on them and the
86 access tokens that automate them, and is the first part of every
87 address: <span className="font-mono text-fg">g1t.sh/workspace/repo</span>.
88 Use one for yourself, and one for each team or company you work with.
89 </p>
Merge Stripe Tax, the card fee on card payments, and one free workspace per person90 {free.length > 0 ? (
91 <OneFreeWorkspace free={free} />
92 ) : (
93 <>
94 <Form method="post" className="mt-8 space-y-4">
95 <Field
96 label="Name in URLs"
97 hint="Lowercase letters, digits and single hyphens. An owner can change it later; old addresses redirect for 90 days."
98 >
99 <div className="flex items-center gap-2 font-mono text-sm">
100 <span className="text-muted">g1t.sh/</span>
101 <Input
102 name="slug"
103 required
104 autoFocus
105 maxLength={39}
106 defaultValue={first ? user.username : ""}
107 pattern="[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*"
108 />
109 </div>
110 </Field>
111 <Field label="Display name (optional)">
112 <Input name="displayName" maxLength={80} />
113 </Field>
114 <p className="text-xs text-faint">
115 A new workspace is free. Each person can own one free workspace; more need the plan on the ones you have.
116 </p>
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)117 <ErrorText>{said?.error}</ErrorText>
Merge Stripe Tax, the card fee on card payments, and one free workspace per person118 <SubmitButton pending="Creating…">Create workspace</SubmitButton>
119 </Form>
120 </>
Agents as a team: lifecycle, merge queue, billing and a new shell121 )}
122 </main>
123 );
124}
Merge Stripe Tax, the card fee on card payments, and one free workspace per person125
126/** Why there is no form: the person owns a free workspace already. */
127function OneFreeWorkspace({ free }: { free: string[] }) {
128 const [first] = free;
129 return (
130 <section className="mt-8 rounded-xl border border-line bg-surface p-5">
131 <h2 className="font-medium">You already own a free workspace</h2>
132 <p className="mt-2 text-sm text-muted">
133 Each person can own one workspace that is not on the g1t plan.{" "}
134 {free.length === 1 ? (
135 <>
136 Yours is <span className="font-mono text-fg">{first}</span>.
137 </>
138 ) : (
139 <>
140 You own {free.length} from before (
141 {free.map((slug, i) => (
142 <span key={slug}>
143 {i > 0 && ", "}
144 <span className="font-mono text-fg">{slug}</span>
145 </span>
146 ))}
147 ), and keep them all.
148 </>
149 )}{" "}
150 A new workspace starts free, so to make one, start the plan on {free.length === 1 ? "it" : "each of them"}, or delete{" "}
151 {free.length === 1 ? "it" : "the ones"} you no longer use.
152 </p>
153 <div className="mt-4 flex flex-wrap items-center gap-3">
154 <ButtonLink to={planHref(first)}>Start the plan on {first}</ButtonLink>
155 <Link to={`/${first}/-/settings`} className="text-sm text-muted hover:text-fg">
156 Workspace settings
157 </Link>
158 </div>
159 <p className="mt-4 text-xs text-faint">
160 The plan is one price for everyone in the workspace, never per person.{" "}
161 <Link to="/pricing" className="underline underline-offset-2 hover:text-fg">
162 Pricing
163 </Link>
164 </p>
165 </section>
166 );
167}

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