Skip to content

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

592 lines25,044 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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look4import {
Merge branch 'worktree-agent-a2013627e5ea4ab13'5 type DataResidency,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look6 RENAME_COOLDOWN_HOURS,
7 SLUG_HOLD_DAYS,
Owners choose who can create teams8 type TeamCreation,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member9 WORKSPACE_RESTORE_DAYS,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look10 type Workspace,
11 type WorkspaceDeletion,
12 isValidNamespace,
13} from "@g1t/contracts";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains14
Agents as a team: lifecycle, merge queue, billing and a new shell15import type { Route } from "./+types/settings";
Social cards for every page: og.g1t.sh16import { page } from "../../lib/meta";
Merge branch 'worktree-agent-aaf03bdceac799c89'17import { useAddresses } from "../../lib/addresses";
Workspace names and icons, and a component kit for every control18import { AvatarField } from "../../components/avatar-field";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look19import { DangerAction, DangerZone } from "../../components/danger-zone";
Merge branch 'worktree-agent-a985c01f43538af01'20import { Button, ErrorText, Field, Input, SubmitButton } from "../../components/ui";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains21import {
22 AlertDialog,
23 AlertDialogCancel,
24 AlertDialogContent,
25 AlertDialogDescription,
26 AlertDialogFooter,
27 AlertDialogHeader,
28 AlertDialogTitle,
29} from "../../components/ui/alert-dialog";
30import { FieldDescription, FieldLabel, Field as FormField } from "../../components/ui/field";
31import { InputAddon, InputGroup, Input as TextInput } from "../../components/ui/input";
Merge branch 'worktree-agent-a2013627e5ea4ab13'32import { RadioGroup, RadioOption } from "../../components/ui/radio-group";
Workspace names and icons, and a component kit for every control33import { readAvatarUpload } from "../../lib/avatar-upload";
Merge branch 'worktree-agent-a2013627e5ea4ab13'34import { deployments, identity, repos } from "../../lib/services.server";
Agents as a team: lifecycle, merge queue, billing and a new shell35import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look36import { forgetWorkspace } from "../../lib/workspace-choice";
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member37import { confirmsSlug, deletionRefusal, whatGoes } from "../../lib/workspace-deletion";
Agents as a team: lifecycle, merge queue, billing and a new shell38
Social cards for every page: og.g1t.sh39export function meta({ params, ...args }: Route.MetaArgs) {
40 return page(args, { title: `Settings · ${params.owner} · g1t` });
Agents as a team: lifecycle, merge queue, billing and a new shell41}
42
Agents and memory, checks and conflicts, profiles, slug renames, custom domains43/** What the address field shows about a new slug, asked for with `?check=`. */
44type Check = { slug: string; available: boolean; message: string | null };
45
46export async function loader({ params, context, request }: Route.LoaderArgs) {
Agents as a team: lifecycle, merge queue, billing and a new shell47 // Owners only; to anyone else the page does not exist.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains48 const viewer = getViewer(context);
49 if (roleIn(viewer, params.owner) !== "owner") {
Agents as a team: lifecycle, merge queue, billing and a new shell50 throw data(null, { status: 404 });
51 }
52 const workspace = await identity.getWorkspace(params.owner);
53 if (!workspace) throw data(null, { status: 404 });
Agents and memory, checks and conflicts, profiles, slug renames, custom domains54 // The address field asks whether a new slug is free as the owner types.
55 // It is a read, so a GET: nothing else on the page reloads for it.
56 const wanted = new URL(request.url).searchParams.get("check");
57 let check: Check | null = null;
58 if (wanted != null && viewer) {
59 const slug = wanted.trim().toLowerCase();
60 const result = await identity.checkWorkspaceRename(viewer, params.owner, slug);
61 check = result.ok
62 ? { slug, available: result.value, message: result.value ? null : "That address is not available." }
63 : { slug, available: false, message: result.error.message };
64 }
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member65 // What deleting it would take with it, and anything in the way, shown
66 // before anyone types. Its apps are counted by deployments.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look67 let deletion: WorkspaceDeletion | null = null;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member68 let apps: number | null = null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look69 if (viewer) {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member70 const [found, usage] = await Promise.all([
71 identity.checkWorkspaceDeletion(viewer, params.owner).catch(() => null),
72 deployments.usage(params.owner, viewer).catch(() => null),
73 ]);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look74 deletion = found?.ok ? found.value : null;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member75 apps = usage?.ok ? usage.value.apps : null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look76 }
Merge branch 'worktree-agent-a2013627e5ea4ab13'77 // Where its repositories are kept: offered once g1t can keep them in the
78 // EU, and shown to a workspace that chose it whatever happens since.
79 const [storage, residency] = await Promise.all([
80 repos.storageOptions().catch(() => ({ euAvailable: false })),
81 identity.workspaceResidency(params.owner).catch(() => null),
82 ]);
83 return { workspace, check, deletion, apps, euAvailable: storage.euAvailable, residency: residency ?? "anywhere" };
Agents as a team: lifecycle, merge queue, billing and a new shell84}
85
86export async function action({ request, params, context }: Route.ActionArgs) {
87 assertSameOrigin(request);
88 const user = requireUser(context, request);
89 const form = await request.formData();
Workspace names and icons, and a component kit for every control90 const intent = form.get("intent");
91 // The icon: identity checks the owner and the image's bytes again.
92 if (intent === "avatar" || intent === "remove-avatar") {
93 let image: string | null = null;
94 if (intent === "avatar") {
95 const upload = await readAvatarUpload(form);
96 if ("error" in upload) return { avatarError: upload.error };
97 image = upload.image;
98 }
99 const result = await identity.setWorkspaceAvatar(user, params.owner, image);
100 if (!result.ok) return { avatarError: result.error.message };
101 return { saved: "avatar" as const };
102 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains103 // A new address: identity checks the owner, the name and the cooldown,
104 // and keeps the old one as a redirect.
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member105 // Deletion: identity checks the owner, the typed name, that it is not
106 // protected and that billing has settled it. Everything in it goes with
107 // it; the owner goes back to their own home, where it is no longer listed.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look108 if (intent === "delete") {
109 const result = await identity.deleteWorkspace(user, params.owner, String(form.get("confirm") ?? ""));
110 if (!result.ok) return { deleteError: result.error.message };
111 const secure = new URL(request.url).protocol === "https:";
112 throw redirect("/", { headers: { "Set-Cookie": forgetWorkspace(secure) } });
113 }
Merge branch 'worktree-agent-a2013627e5ea4ab13'114 // Where new repositories keep their data: identity checks the owner.
115 if (intent === "residency") {
116 const wanted = form.get("residency") === "eu" ? "eu" : "anywhere";
117 const result = await identity.setWorkspaceResidency(user, params.owner, wanted);
118 if (!result.ok) return { residencyError: result.error.message };
119 return { saved: "residency" as const };
120 }
Owners choose who can create teams121 // Who may create teams: identity checks the owner.
122 if (intent === "team-creation") {
123 const wanted: TeamCreation = form.get("teamCreation") === "owners" ? "owners" : "members";
124 const result = await identity.setTeamCreation(user, params.owner, wanted);
125 if (!result.ok) return { teamCreationError: result.error.message };
126 return { saved: "team-creation" as const };
127 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains128 if (intent === "rename") {
129 const newSlug = String(form.get("newSlug") ?? "").trim().toLowerCase();
130 const result = await identity.renameWorkspace(user, params.owner, newSlug);
131 if (!result.ok) return { renameError: result.error.message };
132 throw redirect(`/${result.value.slug}/-/settings`);
133 }
Agents as a team: lifecycle, merge queue, billing and a new shell134 const result = await identity.updateWorkspace(user, params.owner, {
135 name: String(form.get("displayName") ?? ""),
136 description: String(form.get("description") ?? ""),
137 });
138 if (!result.ok) return { error: result.error.message };
139 throw redirect(`/${result.value.slug}`);
140}
141
142export default function WorkspaceSettings({ loaderData, actionData }: Route.ComponentProps) {
143 const { workspace } = loaderData;
144 return (
Workspace names and icons, and a component kit for every control145 <div className="max-w-lg space-y-10">
146 <section>
147 <h2 className="font-medium">Icon</h2>
148 <div className="mt-4">
149 <AvatarField
150 name={workspace.slug}
151 image={workspace.avatar}
152 square
153 error={actionData && "avatarError" in actionData ? actionData.avatarError : undefined}
154 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 shell155 />
Workspace names and icons, and a component kit for every control156 </div>
157 </section>
158
159 <section>
160 <h2 className="font-medium">Workspace details</h2>
161 <Form method="post" className="mt-5 space-y-4">
Merge branch 'worktree-agent-a985c01f43538af01'162 <input type="hidden" name="intent" value="details" />
Workspace names and icons, and a component kit for every control163 <Field
164 label="Display name"
165 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."
166 >
167 <Input name="displayName" maxLength={80} defaultValue={workspace.name} placeholder={workspace.slug} />
168 </Field>
169 <Field label="Description" hint="One line, shown at the top of the workspace's page.">
170 <Input name="description" maxLength={160} defaultValue={workspace.description ?? ""} />
171 </Field>
172 <ErrorText>{actionData && "error" in actionData ? actionData.error : undefined}</ErrorText>
Merge branch 'worktree-agent-a985c01f43538af01'173 <SubmitButton match={{ intent: "details" }} pending="Saving…">
174 Save
175 </SubmitButton>
Workspace names and icons, and a component kit for every control176 </Form>
177 </section>
178
Agents and memory, checks and conflicts, profiles, slug renames, custom domains179 <AddressSection
Merge branch 'worktree-agent-a985c01f43538af01'180 // A new address starts it again, so the dialog is closed on the renamed page.
181 key={workspace.slug}
Agents and memory, checks and conflicts, profiles, slug renames, custom domains182 workspace={workspace}
183 error={actionData && "renameError" in actionData ? actionData.renameError : undefined}
184 />
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look185
Owners choose who can create teams186 <TeamCreationSection
187 // Starts from the saved choice whenever it changes.
188 key={workspace.teamCreation ?? "members"}
189 setting={workspace.teamCreation ?? "members"}
190 saved={Boolean(actionData && "saved" in actionData && actionData.saved === "team-creation")}
191 error={actionData && "teamCreationError" in actionData ? actionData.teamCreationError : undefined}
192 />
193
Merge branch 'worktree-agent-a2013627e5ea4ab13'194 {(loaderData.euAvailable || loaderData.residency === "eu") && (
195 <ResidencySection
Merge branch 'worktree-agent-a985c01f43538af01'196 // Starts from the saved choice whenever it changes.
197 key={loaderData.residency}
Merge branch 'worktree-agent-a2013627e5ea4ab13'198 residency={loaderData.residency}
199 euAvailable={loaderData.euAvailable}
200 saved={Boolean(actionData && "saved" in actionData && actionData.saved === "residency")}
201 error={actionData && "residencyError" in actionData ? actionData.residencyError : undefined}
202 />
203 )}
204
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look205 <DangerZone>
206 <DeleteAction
207 workspace={workspace}
208 deletion={loaderData.deletion}
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member209 apps={loaderData.apps}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look210 error={actionData && "deleteError" in actionData ? actionData.deleteError : undefined}
211 />
212 </DangerZone>
Agents as a team: lifecycle, merge queue, billing and a new shell213 </div>
214 );
215}
Agents and memory, checks and conflicts, profiles, slug renames, custom domains216
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look217/**
Owners choose who can create teams218 * Who may create the workspace's teams. Teams already made stay as they
219 * are, whoever made them.
220 */
221function TeamCreationSection({ setting, saved, error }: { setting: TeamCreation; saved: boolean; error?: string }) {
222 const [choice, setChoice] = useState<TeamCreation>(setting);
223 const navigation = useNavigation();
224 const saving = navigation.state !== "idle" && navigation.formData?.get("intent") === "team-creation";
225 return (
226 <section>
227 <h2 className="font-medium">Teams</h2>
228 <p className="mt-1.5 text-xs text-faint">
229 Who can create teams in the workspace. Whoever creates one becomes its first maintainer. Teams that already
230 exist stay as they are.
231 </p>
232 <Form method="post" className="mt-5 space-y-4">
233 <input type="hidden" name="intent" value="team-creation" />
234 <RadioGroup
235 name="teamCreation"
236 value={choice}
237 onValueChange={(value) => setChoice(value as TeamCreation)}
238 aria-label="Who can create teams"
239 >
240 <RadioOption value="members" label="Any member" description="Every member with a confirmed email address. The default." />
241 <RadioOption value="owners" label="Owners only" description="Members ask an owner to create a team; maintainers still manage their own." />
242 </RadioGroup>
243 <ErrorText>{error}</ErrorText>
244 {saved && !error && (
245 <p role="status" className="text-xs text-muted">
246 Saved.
247 </p>
248 )}
249 <Button type="submit" disabled={saving || choice === setting}>
250 Save
251 </Button>
252 </Form>
253 </section>
254 );
255}
256
257/**
Merge branch 'worktree-agent-a2013627e5ea4ab13'258 * Where the workspace's new repositories keep their git data. Shown only
259 * once g1t has EU storage (or to a workspace that already chose it), so
260 * nobody is offered a choice that does nothing.
261 */
262function ResidencySection({
263 residency,
264 euAvailable,
265 saved,
266 error,
267}: {
268 residency: DataResidency;
269 euAvailable: boolean;
270 saved: boolean;
271 error?: string;
272}) {
273 const [choice, setChoice] = useState<DataResidency>(residency);
274 const navigation = useNavigation();
275 const saving = navigation.state !== "idle" && navigation.formData?.get("intent") === "residency";
276 return (
277 <section>
278 <h2 className="font-medium">Data residency</h2>
279 <p className="mt-1.5 text-xs text-faint">
280 Where the git data of repositories made from now on is stored. Repositories the workspace already has stay
281 where they are; ask support to move them. Issues, pull requests and settings are not affected.
282 </p>
283 <Form method="post" className="mt-5 space-y-4">
284 <input type="hidden" name="intent" value="residency" />
285 <RadioGroup
286 name="residency"
287 value={choice}
288 onValueChange={(value) => setChoice(value as DataResidency)}
289 aria-label="Where new repositories are stored"
290 >
291 <RadioOption value="anywhere" label="Anywhere" description="Wherever g1t stores repositories. The default." />
292 <RadioOption
293 value="eu"
294 label="EU only"
295 description={
296 euAvailable
297 ? "New repositories are stored in the EU, and are not made if EU storage cannot take them."
298 : "EU storage cannot take new repositories right now."
299 }
300 disabled={!euAvailable && residency !== "eu"}
301 />
302 </RadioGroup>
303 <ErrorText>{error}</ErrorText>
304 {saved && !error && (
305 <p role="status" className="text-xs text-muted">
306 Saved.
307 </p>
308 )}
309 <Button type="submit" disabled={saving || choice === residency}>
310 Save
311 </Button>
312 </Form>
313 </section>
314 );
315}
316
317/**
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member318 * Deleting the workspace, with everything in it, in one step. Owners only,
319 * as the whole page is; typed out to confirm. It is kept for
320 * `WORKSPACE_RESTORE_DAYS`, when support can restore it. A protected
321 * workspace says so instead of offering the button.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look322 */
323function DeleteAction({
324 workspace,
325 deletion,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member326 apps,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look327 error,
328}: {
329 workspace: Workspace;
330 deletion: WorkspaceDeletion | null;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member331 apps: number | null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look332 error?: string;
333}) {
334 const [open, setOpen] = useState(Boolean(error));
335 const [confirm, setConfirm] = useState("");
336 const navigation = useNavigation();
337 const deleting = navigation.state !== "idle" && navigation.formData?.get("intent") === "delete";
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member338 const refusal = deletionRefusal(workspace.slug, deletion);
339 const goes = deletion ? whatGoes(deletion, apps) : [];
340 if (deletion?.protected) {
341 return (
342 <DangerAction title="Delete this workspace" action={null}>
343 <span role="status">{refusal}</span>
344 </DangerAction>
345 );
346 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look347 return (
348 <DangerAction
349 title="Delete this workspace"
350 action={
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member351 <Button type="button" variant="danger" disabled={Boolean(refusal)} onClick={() => setOpen(true)}>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look352 Delete workspace
353 </Button>
354 }
355 >
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member356 {refusal ? (
357 <span role="status">{refusal}</span>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look358 ) : (
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member359 <>
360 Everything in it goes with it: repositories, projects, apps, members' access and tokens. It is kept for{" "}
361 {WORKSPACE_RESTORE_DAYS} days, and support can restore it until then.
362 </>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look363 )}
364 <AlertDialog
365 open={open}
366 onOpenChange={(next) => {
367 setOpen(next);
368 setConfirm("");
369 }}
370 >
371 <AlertDialogContent>
372 <Form method="post" className="grid gap-4">
373 <input type="hidden" name="intent" value="delete" />
374 <AlertDialogHeader>
375 <AlertDialogTitle>Delete {workspace.slug}?</AlertDialogTitle>
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member376 <AlertDialogDescription>
377 Everything in it goes now. For {WORKSPACE_RESTORE_DAYS} days, support can restore all of it; after
378 that it is gone for good.
379 </AlertDialogDescription>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look380 </AlertDialogHeader>
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member381 {goes.length > 0 ? (
382 <div className="grid gap-1.5">
383 <p className="text-sm font-medium">What goes with it</p>
384 <ul className="list-disc space-y-1 pl-5 text-sm text-muted">
385 {goes.map((line) => (
386 <li key={line}>{line}</li>
387 ))}
388 </ul>
389 </div>
390 ) : null}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look391 <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted">
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member392 <li>Everyone loses access to it at once, and its access tokens stop working.</li>
393 <li>Its pages, repositories and apps are no longer found, and nothing builds or runs for it.</li>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look394 <li>
395 Anything it owes is charged to its card now, and its plan ends today. Its statements, invoices and
396 audit log are kept.
397 </li>
398 <li>
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member399 To get it back within {WORKSPACE_RESTORE_DAYS} days, an owner writes to support. After that its
400 repositories, projects, apps, webhooks, integrations, secrets and memory are removed for good.
401 </li>
402 <li>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look403 The name <span className="font-mono text-fg">{workspace.slug}</span> is never given to another
404 workspace. Repositories that were transferred out keep redirecting from it.
405 </li>
406 <li>Members keep their own accounts.</li>
407 </ul>
408 <FormField>
409 <FieldLabel htmlFor="confirm-delete">
410 Type <span className="font-mono text-fg">{workspace.slug}</span> to confirm
411 </FieldLabel>
412 <TextInput
413 id="confirm-delete"
414 name="confirm"
415 value={confirm}
416 spellCheck={false}
417 autoCapitalize="off"
418 autoComplete="off"
419 onChange={(event) => setConfirm(event.target.value)}
420 className="font-mono"
421 />
422 </FormField>
423 <ErrorText>{error}</ErrorText>
424 <AlertDialogFooter>
425 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member426 <Button type="submit" variant="danger" disabled={!confirmsSlug(workspace.slug, confirm) || deleting}>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look427 {deleting ? "Deleting…" : "Delete workspace"}
428 </Button>
429 </AlertDialogFooter>
430 </Form>
431 </AlertDialogContent>
432 </AlertDialog>
433 </DangerAction>
434 );
435}
436
Agents and memory, checks and conflicts, profiles, slug renames, custom domains437/**
438 * The workspace's slug, the first part of every address under it. Owners
439 * change it here, apart from the display name; the old one redirects for
440 * `SLUG_HOLD_DAYS`.
441 */
442function AddressSection({ workspace, error }: { workspace: Workspace; error?: string }) {
443 const [slug, setSlug] = useState(workspace.slug);
444 const [open, setOpen] = useState(false);
445 const [confirm, setConfirm] = useState("");
446 const checker = useFetcher<typeof loader>();
Merge branch 'worktree-agent-aaf03bdceac799c89'447 const { site } = useAddresses();
Agents and memory, checks and conflicts, profiles, slug renames, custom domains448 const navigation = useNavigation();
449 const renaming = navigation.state !== "idle" && navigation.formData?.get("intent") === "rename";
450
451 const wanted = slug.trim().toLowerCase();
452 const unchanged = wanted === workspace.slug;
453 const wellFormed = isValidNamespace(wanted);
454
455 // The shape is checked here; whether the name is free, once the owner
456 // stops typing.
457 const load = checker.load;
458 useEffect(() => {
459 if (unchanged || !wellFormed) return;
460 const timer = setTimeout(() => {
461 load(`/${workspace.slug}/-/settings?check=${encodeURIComponent(wanted)}`);
462 }, 400);
463 return () => clearTimeout(timer);
464 }, [load, wanted, unchanged, wellFormed, workspace.slug]);
465
466 // An answer only counts for what is in the field now.
467 const answer = checker.data?.check?.slug === wanted ? checker.data.check : null;
468 const available = !unchanged && wellFormed && answer?.available === true;
469
470 let status: { text: string; tone: "muted" | "ok" | "bad" } | null = null;
471 if (!wanted) {
472 status = { text: "Enter the new address.", tone: "muted" };
473 } else if (unchanged) {
474 status = null;
475 } else if (!wellFormed) {
476 status = {
477 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.",
478 tone: "bad",
479 };
480 } else if (!answer) {
481 status = { text: "Checking…", tone: "muted" };
482 } else if (answer.available) {
483 status = { text: "Available.", tone: "ok" };
484 } else {
485 status = { text: answer.message ?? "That address is not available.", tone: "bad" };
486 }
487
488 return (
489 <section>
490 <h2 className="font-medium">Address</h2>
491 <p className="mt-1.5 text-xs text-faint">
492 The workspace's slug: the first part of every page, clone URL and API path under it. Changing it does
493 not change the display name. The old address redirects for {SLUG_HOLD_DAYS} days.
494 </p>
495 <div className="mt-5 space-y-4">
496 <FormField>
497 <FieldLabel htmlFor="workspace-slug">Slug</FieldLabel>
498 <InputGroup>
499 <InputAddon className="border-r border-line bg-surface font-mono">g1t.sh/</InputAddon>
500 <TextInput
501 id="workspace-slug"
502 value={slug}
503 maxLength={39}
504 spellCheck={false}
505 autoCapitalize="off"
506 onChange={(event) => setSlug(event.target.value.toLowerCase())}
507 aria-invalid={status?.tone === "bad" || undefined}
508 className="pl-2 font-mono"
509 />
510 </InputGroup>
511 <FieldDescription>
512 <span className="font-mono text-muted">
513 g1t.sh/<span className={unchanged ? undefined : "text-fg"}>{wanted || workspace.slug}</span>
514 </span>
515 {status && (
516 <span
517 role="status"
518 className={`mt-1 block ${
Lavender is the accent; mint means success519 status.tone === "ok" ? "text-success" : status.tone === "bad" ? "text-danger" : ""
Agents and memory, checks and conflicts, profiles, slug renames, custom domains520 }`}
521 >
522 {status.text}
523 </span>
524 )}
525 </FieldDescription>
526 </FormField>
527 {!open && <ErrorText>{error}</ErrorText>}
528 <AlertDialog
529 open={open}
530 onOpenChange={(next) => {
531 setOpen(next);
532 setConfirm("");
533 }}
534 >
535 <Button type="button" variant="quiet" disabled={!available} onClick={() => setOpen(true)}>
536 Change address
537 </Button>
538 <AlertDialogContent>
539 {/* The dialog stays open while the form posts: success leaves
540 the page for the new address, and a failure shows here. */}
541 <Form method="post" className="grid gap-4">
542 <input type="hidden" name="intent" value="rename" />
543 <input type="hidden" name="newSlug" value={wanted} />
544 <AlertDialogHeader>
545 <AlertDialogTitle>Change the address to g1t.sh/{wanted}?</AlertDialogTitle>
546 <AlertDialogDescription>
547 Every page, repository and API path under g1t.sh/{workspace.slug} moves to g1t.sh/{wanted}.
548 </AlertDialogDescription>
549 </AlertDialogHeader>
550 <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted">
551 <li>Old links and git remotes redirect to the new address for {SLUG_HOLD_DAYS} days.</li>
552 <li>
553 Deployed apps move to <span className="font-mono">*.g1t.page</span> addresses with the new
554 name. The old addresses redirect for {SLUG_HOLD_DAYS} days.
555 </li>
556 <li>
557 Update your git remotes, for example{" "}
558 <code className="font-mono text-xs break-all text-fg">
Merge branch 'worktree-agent-aaf03bdceac799c89'559 git remote set-url origin {site}/{wanted}/&lt;repo&gt;.git
Agents and memory, checks and conflicts, profiles, slug renames, custom domains560 </code>
561 , and any URLs written into code, API clients and MCP clients.
562 </li>
563 <li>After {SLUG_HOLD_DAYS} days, anyone can take the name {workspace.slug}.</li>
564 <li>You can change the address again after {RENAME_COOLDOWN_HOURS} hours.</li>
565 </ul>
566 <FormField>
567 <FieldLabel htmlFor="confirm-slug">
568 Type <span className="font-mono text-fg">{wanted}</span> to confirm
569 </FieldLabel>
570 <TextInput
571 id="confirm-slug"
572 value={confirm}
573 spellCheck={false}
574 autoCapitalize="off"
575 onChange={(event) => setConfirm(event.target.value)}
576 className="font-mono"
577 />
578 </FormField>
579 <ErrorText>{error}</ErrorText>
580 <AlertDialogFooter>
581 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
582 <Button type="submit" disabled={confirm.trim().toLowerCase() !== wanted || renaming}>
583 {renaming ? "Changing…" : "Change address"}
584 </Button>
585 </AlertDialogFooter>
586 </Form>
587 </AlertDialogContent>
588 </AlertDialog>
589 </div>
590 </section>
591 );
592}

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