flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

g1t/apps/web/app/routes/workspace/settings.tsx

283 lines12,308 bytesCodeBlame

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.

Agents and memory, checks and conflicts, profiles, slug renames, custom domains1import { useEffect, useState } from "react";
2import { Form, data, redirect, useFetcher, useNavigation } from "react-router";
Agents as a team: lifecycle, merge queue, billing and a new shell3
Agents and memory, checks and conflicts, profiles, slug renames, custom domains4import { RENAME_COOLDOWN_HOURS, SLUG_HOLD_DAYS, type Workspace, isValidNamespace } from "@g1t/contracts";
5
Agents as a team: lifecycle, merge queue, billing and a new shell6import type { Route } from "./+types/settings";
Social cards for every page: og.g1t.sh7import { page } from "../../lib/meta";
Workspace names and icons, and a component kit for every control8import { AvatarField } from "../../components/avatar-field";
Agents as a team: lifecycle, merge queue, billing and a new shell9import { Button, ErrorText, Field, Input } from "../../components/ui";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains10import {
11 AlertDialog,
12 AlertDialogCancel,
13 AlertDialogContent,
14 AlertDialogDescription,
15 AlertDialogFooter,
16 AlertDialogHeader,
17 AlertDialogTitle,
18} from "../../components/ui/alert-dialog";
19import { FieldDescription, FieldLabel, Field as FormField } from "../../components/ui/field";
20import { InputAddon, InputGroup, Input as TextInput } from "../../components/ui/input";
Workspace names and icons, and a component kit for every control21import { readAvatarUpload } from "../../lib/avatar-upload";
Agents as a team: lifecycle, merge queue, billing and a new shell22import { identity } from "../../lib/services.server";
23import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
24
Social cards for every page: og.g1t.sh25export function meta({ params, ...args }: Route.MetaArgs) {
26 return page(args, { title: `Settings · ${params.owner} · g1t` });
Agents as a team: lifecycle, merge queue, billing and a new shell27}
28
Agents and memory, checks and conflicts, profiles, slug renames, custom domains29/** What the address field shows about a new slug, asked for with `?check=`. */
30type Check = { slug: string; available: boolean; message: string | null };
31
32export async function loader({ params, context, request }: Route.LoaderArgs) {
Agents as a team: lifecycle, merge queue, billing and a new shell33 // Owners only; to anyone else the page does not exist.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains34 const viewer = getViewer(context);
35 if (roleIn(viewer, params.owner) !== "owner") {
Agents as a team: lifecycle, merge queue, billing and a new shell36 throw data(null, { status: 404 });
37 }
38 const workspace = await identity.getWorkspace(params.owner);
39 if (!workspace) throw data(null, { status: 404 });
Agents and memory, checks and conflicts, profiles, slug renames, custom domains40 // The address field asks whether a new slug is free as the owner types.
41 // It is a read, so a GET: nothing else on the page reloads for it.
42 const wanted = new URL(request.url).searchParams.get("check");
43 let check: Check | null = null;
44 if (wanted != null && viewer) {
45 const slug = wanted.trim().toLowerCase();
46 const result = await identity.checkWorkspaceRename(viewer, params.owner, slug);
47 check = result.ok
48 ? { slug, available: result.value, message: result.value ? null : "That address is not available." }
49 : { slug, available: false, message: result.error.message };
50 }
51 return { workspace, check };
Agents as a team: lifecycle, merge queue, billing and a new shell52}
53
54export async function action({ request, params, context }: Route.ActionArgs) {
55 assertSameOrigin(request);
56 const user = requireUser(context, request);
57 const form = await request.formData();
Workspace names and icons, and a component kit for every control58 const intent = form.get("intent");
59 // The icon: identity checks the owner and the image's bytes again.
60 if (intent === "avatar" || intent === "remove-avatar") {
61 let image: string | null = null;
62 if (intent === "avatar") {
63 const upload = await readAvatarUpload(form);
64 if ("error" in upload) return { avatarError: upload.error };
65 image = upload.image;
66 }
67 const result = await identity.setWorkspaceAvatar(user, params.owner, image);
68 if (!result.ok) return { avatarError: result.error.message };
69 return { saved: "avatar" as const };
70 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains71 // A new address: identity checks the owner, the name and the cooldown,
72 // and keeps the old one as a redirect.
73 if (intent === "rename") {
74 const newSlug = String(form.get("newSlug") ?? "").trim().toLowerCase();
75 const result = await identity.renameWorkspace(user, params.owner, newSlug);
76 if (!result.ok) return { renameError: result.error.message };
77 throw redirect(`/${result.value.slug}/-/settings`);
78 }
Agents as a team: lifecycle, merge queue, billing and a new shell79 const result = await identity.updateWorkspace(user, params.owner, {
80 name: String(form.get("displayName") ?? ""),
81 description: String(form.get("description") ?? ""),
82 });
83 if (!result.ok) return { error: result.error.message };
84 throw redirect(`/${result.value.slug}`);
85}
86
87export default function WorkspaceSettings({ loaderData, actionData }: Route.ComponentProps) {
88 const { workspace } = loaderData;
89 return (
Workspace names and icons, and a component kit for every control90 <div className="max-w-lg space-y-10">
91 <section>
92 <h2 className="font-medium">Icon</h2>
93 <div className="mt-4">
94 <AvatarField
95 name={workspace.slug}
96 image={workspace.avatar}
97 square
98 error={actionData && "avatarError" in actionData ? actionData.avatarError : undefined}
99 about="Shown beside the workspace's name everywhere on g1t, and on its link previews. Without one, g1t draws its first letter."
Agents as a team: lifecycle, merge queue, billing and a new shell100 />
Workspace names and icons, and a component kit for every control101 </div>
102 </section>
103
104 <section>
105 <h2 className="font-medium">Workspace details</h2>
106 <Form method="post" className="mt-5 space-y-4">
107 <Field
108 label="Display name"
109 hint="How people see the workspace: in the sidebar, at the top of its page and in link previews. Up to 80 characters; spaces and capitals are fine."
110 >
111 <Input name="displayName" maxLength={80} defaultValue={workspace.name} placeholder={workspace.slug} />
112 </Field>
113 <Field label="Description" hint="One line, shown at the top of the workspace's page.">
114 <Input name="description" maxLength={160} defaultValue={workspace.description ?? ""} />
115 </Field>
116 <ErrorText>{actionData && "error" in actionData ? actionData.error : undefined}</ErrorText>
117 <Button type="submit">Save</Button>
118 </Form>
119 </section>
120
Agents and memory, checks and conflicts, profiles, slug renames, custom domains121 <AddressSection
122 workspace={workspace}
123 error={actionData && "renameError" in actionData ? actionData.renameError : undefined}
124 />
Agents as a team: lifecycle, merge queue, billing and a new shell125 </div>
126 );
127}
Agents and memory, checks and conflicts, profiles, slug renames, custom domains128
129/**
130 * The workspace's slug, the first part of every address under it. Owners
131 * change it here, apart from the display name; the old one redirects for
132 * `SLUG_HOLD_DAYS`.
133 */
134function AddressSection({ workspace, error }: { workspace: Workspace; error?: string }) {
135 const [slug, setSlug] = useState(workspace.slug);
136 const [open, setOpen] = useState(false);
137 const [confirm, setConfirm] = useState("");
138 const checker = useFetcher<typeof loader>();
139 const navigation = useNavigation();
140 const renaming = navigation.state !== "idle" && navigation.formData?.get("intent") === "rename";
141
142 const wanted = slug.trim().toLowerCase();
143 const unchanged = wanted === workspace.slug;
144 const wellFormed = isValidNamespace(wanted);
145
146 // The shape is checked here; whether the name is free, once the owner
147 // stops typing.
148 const load = checker.load;
149 useEffect(() => {
150 if (unchanged || !wellFormed) return;
151 const timer = setTimeout(() => {
152 load(`/${workspace.slug}/-/settings?check=${encodeURIComponent(wanted)}`);
153 }, 400);
154 return () => clearTimeout(timer);
155 }, [load, wanted, unchanged, wellFormed, workspace.slug]);
156
157 // An answer only counts for what is in the field now.
158 const answer = checker.data?.check?.slug === wanted ? checker.data.check : null;
159 const available = !unchanged && wellFormed && answer?.available === true;
160
161 let status: { text: string; tone: "muted" | "ok" | "bad" } | null = null;
162 if (!wanted) {
163 status = { text: "Enter the new address.", tone: "muted" };
164 } else if (unchanged) {
165 status = null;
166 } else if (!wellFormed) {
167 status = {
168 text: "Use lowercase letters, numbers and single hyphens, up to 39 characters, starting and ending with a letter or number. Some words, such as new and settings, are reserved.",
169 tone: "bad",
170 };
171 } else if (!answer) {
172 status = { text: "Checking…", tone: "muted" };
173 } else if (answer.available) {
174 status = { text: "Available.", tone: "ok" };
175 } else {
176 status = { text: answer.message ?? "That address is not available.", tone: "bad" };
177 }
178
179 return (
180 <section>
181 <h2 className="font-medium">Address</h2>
182 <p className="mt-1.5 text-xs text-faint">
183 The workspace's slug: the first part of every page, clone URL and API path under it. Changing it does
184 not change the display name. The old address redirects for {SLUG_HOLD_DAYS} days.
185 </p>
186 <div className="mt-5 space-y-4">
187 <FormField>
188 <FieldLabel htmlFor="workspace-slug">Slug</FieldLabel>
189 <InputGroup>
190 <InputAddon className="border-r border-line bg-surface font-mono">g1t.sh/</InputAddon>
191 <TextInput
192 id="workspace-slug"
193 value={slug}
194 maxLength={39}
195 spellCheck={false}
196 autoCapitalize="off"
197 onChange={(event) => setSlug(event.target.value.toLowerCase())}
198 aria-invalid={status?.tone === "bad" || undefined}
199 className="pl-2 font-mono"
200 />
201 </InputGroup>
202 <FieldDescription>
203 <span className="font-mono text-muted">
204 g1t.sh/<span className={unchanged ? undefined : "text-fg"}>{wanted || workspace.slug}</span>
205 </span>
206 {status && (
207 <span
208 role="status"
209 className={`mt-1 block ${
210 status.tone === "ok" ? "text-accent" : status.tone === "bad" ? "text-danger" : ""
211 }`}
212 >
213 {status.text}
214 </span>
215 )}
216 </FieldDescription>
217 </FormField>
218 {!open && <ErrorText>{error}</ErrorText>}
219 <AlertDialog
220 open={open}
221 onOpenChange={(next) => {
222 setOpen(next);
223 setConfirm("");
224 }}
225 >
226 <Button type="button" variant="quiet" disabled={!available} onClick={() => setOpen(true)}>
227 Change address
228 </Button>
229 <AlertDialogContent>
230 {/* The dialog stays open while the form posts: success leaves
231 the page for the new address, and a failure shows here. */}
232 <Form method="post" className="grid gap-4">
233 <input type="hidden" name="intent" value="rename" />
234 <input type="hidden" name="newSlug" value={wanted} />
235 <AlertDialogHeader>
236 <AlertDialogTitle>Change the address to g1t.sh/{wanted}?</AlertDialogTitle>
237 <AlertDialogDescription>
238 Every page, repository and API path under g1t.sh/{workspace.slug} moves to g1t.sh/{wanted}.
239 </AlertDialogDescription>
240 </AlertDialogHeader>
241 <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted">
242 <li>Old links and git remotes redirect to the new address for {SLUG_HOLD_DAYS} days.</li>
243 <li>
244 Deployed apps move to <span className="font-mono">*.g1t.page</span> addresses with the new
245 name. The old addresses redirect for {SLUG_HOLD_DAYS} days.
246 </li>
247 <li>
248 Update your git remotes, for example{" "}
249 <code className="font-mono text-xs break-all text-fg">
250 git remote set-url origin https://g1t.sh/{wanted}/&lt;repo&gt;.git
251 </code>
252 , and any URLs written into code, API clients and MCP clients.
253 </li>
254 <li>After {SLUG_HOLD_DAYS} days, anyone can take the name {workspace.slug}.</li>
255 <li>You can change the address again after {RENAME_COOLDOWN_HOURS} hours.</li>
256 </ul>
257 <FormField>
258 <FieldLabel htmlFor="confirm-slug">
259 Type <span className="font-mono text-fg">{wanted}</span> to confirm
260 </FieldLabel>
261 <TextInput
262 id="confirm-slug"
263 value={confirm}
264 spellCheck={false}
265 autoCapitalize="off"
266 onChange={(event) => setConfirm(event.target.value)}
267 className="font-mono"
268 />
269 </FormField>
270 <ErrorText>{error}</ErrorText>
271 <AlertDialogFooter>
272 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
273 <Button type="submit" disabled={confirm.trim().toLowerCase() !== wanted || renaming}>
274 {renaming ? "Changing…" : "Change address"}
275 </Button>
276 </AlertDialogFooter>
277 </Form>
278 </AlertDialogContent>
279 </AlertDialog>
280 </div>
281 </section>
282 );
283}