Skip to content

Compare changes

Choose two branches to see what one has that the other does not, then open a pull request for it.

Open a pull request

3 commits

97 files+872−5410/97 viewed
+6−2
259259 difference appears on the statement as a correction, such as
260260 *Correction to "Work on acme/api#12": AI Gateway priced its 41 model
261261 requests at $0.0312, not $0.0298*. A run whose sandbox stopped without
262− reporting is charged from the gateway's logs instead of not at all.
262+ reporting is charged from the gateway's logs instead of not at all. When
263+ the gateway has no price for a model a run used, the run is never settled
264+ below what its sandbox reported, and the correction says so.
263265 - **Cloudflare.** Every day, g1t checks what Cloudflare billed its account
264266 against what was used: Containers and the Durable Objects behind them
265267 against the seconds containers ran, Workers for Platforms per request
615617 charged, and usage is still recorded at what it cost, so the Usage page
616618 stays accurate.
617619 - **Custom**: a discount on usage, a limit of its own, or a larger share
618− of the pools, sometimes until a date.
620+ of the pools, sometimes until a date. A discount comes off each usage
621+ charge, and the statement line says how much off (*(20% off)*); prices
622+ themselves stay the public ones.
619623 - **A longer audit log**: up to 400 days for every workspace the account
620624 pays for, in place of the plan's 7 or 90. See
621625 [how long it is kept](/guides/audit-log/#how-long-it-is-kept).
+1−0
214214 ["free use", o.givenFreeMicros ?? 0],
215215 ["trial", o.givenTrialMicros ?? 0],
216216 ["open-source pool", o.givenPoolMicros ?? 0],
217+ ["discounts", o.givenDiscountMicros ?? 0],
217218 ].filter(([, micros]) => (micros as number) > 0) as [string, number][];
218219 const rows: { title: string; note: string; in: number | null; cost: number; result: number | null; tone?: "danger" | "warn" | "muted" }[] = [
219220 {
+10−4
11 import { Form } from "react-router";
22
3−import { Button } from "./ui";
3+import { SubmitButton } from "./ui";
44
5−/** One row's way out of a list in your settings: a key, a token, an application. */
5+/**
6+ * One row's way out of a list in your settings: a key, a token, an
7+ * application. It says it is working until the row has gone.
8+ */
69 export function DeleteButton({
710 intent,
811 id,
912 label = "Delete",
13+ pending = "Deleting…",
1014 }: {
1115 intent: string;
1216 id: string;
1317 label?: string;
18+ /** The words while it works. */
19+ pending?: string;
1420 }) {
1521 return (
1622 <Form method="post" className="ml-auto">
1723 <input type="hidden" name="intent" value={intent} />
1824 <input type="hidden" name="id" value={id} />
19− <Button variant="quiet" type="submit">
25+ <SubmitButton variant="quiet" pending={pending} match={{ intent, id }}>
2026 {label}
21− </Button>
27+ </SubmitButton>
2228 </Form>
2329 );
2430 }
+7−7
33 * nothing proves a change works, and a button that opens a pull request
44 * adding a starter workflow for the stack g1t finds (routes/repo/add-ci.ts).
55 */
6−import { FlaskConical, LoaderCircle, Plus } from "lucide-react";
6+import { FlaskConical, Plus } from "lucide-react";
77 import { useFetcher } from "react-router";
88
9−import { Button, ErrorText } from "./ui";
9+import { ErrorText, SubmitButton } from "./ui";
1010
1111 export function AddCiPrompt({
1212 owner,
2222 compact?: boolean;
2323 }) {
2424 const fetcher = useFetcher<{ error?: string }>();
25− const adding = fetcher.state !== "idle";
25+
2626 return (
2727 <div className={compact ? "flex gap-3 px-4 py-3" : "flex gap-3 rounded-xl border border-dashed border-line-strong bg-surface p-4"}>
2828 <FlaskConical size={16} className="mt-0.5 shrink-0 text-warn" />
3434 </p>
3535 {canAdd && (
3636 <fetcher.Form method="post" action={`/${owner}/${repo}/add-ci`} className="mt-3 flex flex-wrap items-center gap-3">
37− <Button variant="primary" type="submit" disabled={adding}>
38− {adding ? <LoaderCircle size={14} className="animate-spin" /> : <Plus size={14} />}
39− {adding ? "Opening a pull request…" : "Add CI"}
40− </Button>
37+ <SubmitButton variant="primary" fetcher={fetcher} pending="Opening a pull request…">
38+ <Plus size={14} />
39+ Add CI
40+ </SubmitButton>
4141 <span className="text-xs text-faint">
4242 Opens a pull request with a starter workflow for the stack g1t finds. You can change it before merging.
4343 </span>
+13−8
55 * `agents.json` resource route, so every place behaves the same.
66 */
77 import { Bot, CircleSlash, Clock, Coins, Gauge, Loader2, MessageSquare, OctagonX, Square, TriangleAlert } from "lucide-react";
8−import { type ReactNode, useEffect, useMemo, useState } from "react";
8+import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
99 import { Link, useFetcher } from "react-router";
1010 import { useRefreshWhile } from "../lib/refresh";
1111
2323
2424 import { RunAudit } from "./audit";
2525 import { STAGE_LABEL, StageDots } from "./lifecycle";
26−import { Avatar, TimeAgo } from "./ui";
26+import { Avatar, SubmitButton, TimeAgo } from "./ui";
2727 import {
2828 AlertDialog,
2929 AlertDialogAction,
157157 export function MessageRun({ run }: { run: AgentRun }) {
158158 const fetcher = useFetcher<ActionResult>();
159159 const [open, setOpen] = useState(false);
160+ const form = useRef<HTMLFormElement>(null);
161+ // Sent: the box empties, and the notice below says where it went.
162+ useEffect(() => {
163+ if (fetcher.state === "idle" && fetcher.data?.ok) form.current?.reset();
164+ }, [fetcher.state, fetcher.data]);
160165 const live = takesMessages(run.kind) && isActiveRun(run.status);
161166 if (run.number == null) return null;
162167 return (
174179 : "This run does not read messages while it works. Your message waits on the pull request and is given to the agent's next run there."}
175180 </DialogDescription>
176181 </DialogHeader>
177− <fetcher.Form method="post" action={actionUrl(run)} className="space-y-3">
182+ <fetcher.Form ref={form} method="post" action={actionUrl(run)} className="space-y-3">
178183 <input type="hidden" name="intent" value="message" />
179184 <input type="hidden" name="number" value={run.number} />
180185 <textarea
189194 {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>}
190195 {fetcher.data?.ok && fetcher.data.notice && <p className="text-sm text-accent">{fetcher.data.notice}</p>}
191196 <div className="flex justify-end">
192− <button
193− type="submit"
194− disabled={fetcher.state !== "idle"}
197+ <SubmitButton
198+ fetcher={fetcher}
199+ pending="Sending…"
195200 className="inline-flex items-center gap-2 rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50"
196201 >
197− {fetcher.state !== "idle" ? "Sending…" : "Send"}
198− </button>
202+ Send
203+ </SubmitButton>
199204 </div>
200205 </fetcher.Form>
201206 </DialogContent>
+15−10
1−import { ImageUp, Trash2 } from "lucide-react";
1+import { ImageUp, LoaderCircle, Trash2 } from "lucide-react";
22 import { useRef } from "react";
3−import { Form, useNavigation } from "react-router";
3+import { Form } from "react-router";
44
55 import { AVATAR_ACCEPT } from "../lib/avatar-upload";
6−import { Avatar, Button, ErrorText } from "./ui";
6+import { Avatar, ErrorText, SubmitButton, usePending } from "./ui";
77
88 /**
99 * Uploading an avatar, as GitHub's settings do: the one shown now, a
2525 error?: string;
2626 about: string;
2727 }) {
28− const navigation = useNavigation();
2928 const form = useRef<HTMLFormElement>(null);
30− const intent = navigation.formData?.get("intent");
31− const busy = navigation.state === "submitting" && (intent === "avatar" || intent === "remove-avatar");
29+ // Working until the new picture (or the letter) is the one shown.
30+ const uploading = usePending({ intent: "avatar" });
31+ const removing = usePending({ intent: "remove-avatar" });
32+ const busy = uploading || removing;
3233 return (
3334 <div className="flex items-start gap-4">
3435 <Avatar name={name} image={image} size={64} square={square} />
4243 busy ? "pointer-events-none opacity-50" : ""
4344 }`}
4445 >
45− <ImageUp size={15} />
46− {busy && intent === "avatar" ? "Uploading…" : image ? "Upload a new image" : "Upload an image"}
46+ {uploading ? (
47+ <LoaderCircle size={15} aria-hidden="true" className="animate-spin" />
48+ ) : (
49+ <ImageUp size={15} />
50+ )}
51+ {uploading ? "Uploading…" : image ? "Upload a new image" : "Upload an image"}
4752 <input
4853 type="file"
4954 name="avatar"
5863 {image && (
5964 <Form method="post">
6065 <input type="hidden" name="intent" value="remove-avatar" />
61− <Button type="submit" variant="quiet" disabled={busy}>
66+ <SubmitButton variant="quiet" pending="Removing…" match={{ intent: "remove-avatar" }} disabled={uploading}>
6267 <Trash2 size={15} />
6368 Remove
64− </Button>
69+ </SubmitButton>
6570 </Form>
6671 )}
6772 </div>
+29−32
2424 spendRange,
2525 wholeDollars,
2626 } from "../lib/billing";
27−import { Button, ErrorText } from "./ui";
27+import { ErrorText, SubmitButton } from "./ui";
2828 import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
2929
3030 /** An error the action returned, for the section it belongs to. */
181181 owner,
182182 enabled,
183183 live,
184− busy,
185184 meters,
186185 error,
187186 }: {
191190 owner: boolean;
192191 enabled: boolean;
193192 live: boolean;
194− busy: boolean;
195193 /** This month's usage by meter, from billing's `usage_meters`. */
196194 meters: MeterUsage[] | null;
197195 error?: string;
271269 <Form method="post" className="mt-5 flex flex-wrap items-center gap-3">
272270 <input type="hidden" name="feature" value={plan?.feature ?? "plan"} />
273271 {status.kind === "free" || status.kind === "trial" ? (
274− <Button variant="accent" type="submit" name="intent" value="subscribe" disabled={busy}>
272+ <SubmitButton variant="accent" name="intent" value="subscribe" pending="Opening Stripe…">
275273 <CreditCard size={14} />
276274 Start the g1t plan
277− </Button>
275+ </SubmitButton>
278276 ) : status.kind === "canceling" ? (
279− <Button variant="accent" type="submit" name="intent" value="resume" disabled={busy}>
277+ <SubmitButton variant="accent" name="intent" value="resume" pending="Saving…">
280278 Keep the plan
281− </Button>
279+ </SubmitButton>
282280 ) : null}
283281 {(status.kind === "paid" || status.kind === "canceling" || status.kind === "past_due") && (
284− <Button variant={status.kind === "past_due" ? "accent" : "quiet"} type="submit" name="intent" value="portal" disabled={busy}>
282+ <SubmitButton variant={status.kind === "past_due" ? "accent" : "quiet"} name="intent" value="portal" pending="Opening Stripe…">
285283 {status.kind === "past_due" ? "Update payment on Stripe" : "Manage on Stripe"}
286284 <ArrowUpRight size={14} />
287− </Button>
285+ </SubmitButton>
288286 )}
289287 {status.kind === "paid" && subscription && (
290− <Button variant="quiet" type="submit" name="intent" value="cancel" disabled={busy}>
288+ <SubmitButton variant="quiet" name="intent" value="cancel" pending="Saving…">
291289 End at the end of the period
292− </Button>
290+ </SubmitButton>
293291 )}
294292 {!live && (status.kind === "free" || status.kind === "trial") && (
295293 <span className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</span>
369367 trialMicros,
370368 owner,
371369 enabled,
372− busy,
373370 error,
374371 }: {
375372 entitlements: Entitlements | null;
376373 trialMicros: number;
377374 owner: boolean;
378375 enabled: boolean;
379− busy: boolean;
380376 error?: string;
381377 }) {
382378 const verified = entitlements?.trialVerified ?? false;
397393 {verified && left > 0 && <Meter label="Trial used" used={Math.max(0, trialMicros - left)} of={trialMicros} />}
398394 {!verified && enabled && owner && (
399395 <Form method="post" className="mt-4 flex flex-wrap items-center gap-3">
400− <Button variant="quiet" type="submit" name="intent" value="card-check" disabled={busy}>
396+ <SubmitButton variant="quiet" name="intent" value="card-check" pending="Opening Stripe…">
401397 <ShieldCheck size={14} />
402398 Check a card
403− </Button>
399+ </SubmitButton>
404400 <span className="text-xs text-faint">Trials come from a monthly pool; when it is given out, new ones start on the 1st.</span>
405401 </Form>
406402 )}
412408
413409 // --- Spend limit -----------------------------------------------------------------------
414410
415−export function SpendLimitCard({ limit, owner, busy, error }: { limit: Limit; owner: boolean; busy: boolean; error?: string }) {
411+export function SpendLimitCard({ limit, owner, error }: { limit: Limit; owner: boolean; error?: string }) {
416412 const range = spendRange(limit);
417413 const spent = limit.spentMicros ?? 0;
418414 const spendLimit = limit.spendLimitMicros;
513509 </RadioGroup>
514510 </fieldset>
515511 <div className="mt-3 flex flex-wrap items-center gap-3">
516− <Button variant="quiet" type="submit" disabled={busy}>
512+ <SubmitButton variant="quiet" match={{ intent: "spend-limit" }} pending="Saving…">
517513 Save limit
518− </Button>
514+ </SubmitButton>
519515 <a href="#raise" className="text-sm text-muted hover:text-fg">
520516 Need more? Raise my limit
521517 </a>
559555 );
560556 }
561557
562−export function RaiseCard({ requests, owner, busy, error }: { requests: LimitRequest[]; owner: boolean; busy: boolean; error?: string }) {
558+export function RaiseCard({ requests, owner, error }: { requests: LimitRequest[]; owner: boolean; error?: string }) {
563559 const mine = requests.filter((request) => request.kind === "limit");
564560 const waiting = mine.some((request) => request.status === "open");
565561 return (
592588 className={`${CONTROL} w-full`}
593589 />
594590 </label>
595− <Button variant="quiet" type="submit" disabled={busy}>
591+ <SubmitButton variant="quiet" match={{ intent: "request", kind: "limit" }} pending="Sending…">
596592 Send the request
597− </Button>
593+ </SubmitButton>
598594 <ErrorText>{error}</ErrorText>
599595 </Form>
600596 )}
607603
608604 // --- Prepay -----------------------------------------------------------------------
609605
610−export function PrepayCard({ prepaidMicros, owner, live, busy, error }: { prepaidMicros: number; owner: boolean; live: boolean; busy: boolean; error?: string }) {
606+export function PrepayCard({ prepaidMicros, owner, live, error }: { prepaidMicros: number; owner: boolean; live: boolean; error?: string }) {
611607 return (
612608 <Card
613609 id="prepay"
626622 <input type="hidden" name="intent" value="prepay" />
627623 <div className="flex flex-wrap items-center gap-2">
628624 {PREPAY.presets.map((amount) => (
629− <Button key={amount} variant="quiet" type="submit" name="amount" value={amount} disabled={busy}>
625+ <SubmitButton key={amount} variant="quiet" name="amount" value={amount} match={{ intent: "prepay" }} pending="Opening Stripe…">
630626 {wholeDollars(amount * 1_000_000)}
631− </Button>
627+ </SubmitButton>
632628 ))}
633629 <span className="text-sm text-muted">or</span>
634630 <DollarInput name="custom" label="Amount to prepay in dollars" placeholder="250" />
635− <Button variant="accent" type="submit" disabled={busy}>
631+ {/* An empty amount, so a preset's press is not this button's: the typed amount is used. */}
632+ <SubmitButton variant="accent" name="amount" value="" match={{ intent: "prepay" }} pending="Opening Stripe…">
636633 <CreditCard size={14} />
637634 Prepay
638− </Button>
635+ </SubmitButton>
639636 </div>
640637 <RadioGroup name="method" defaultValue="card" className="flex flex-wrap gap-x-5 gap-y-2 text-sm">
641638 <label className="flex cursor-pointer items-center gap-2">
659656
660657 // --- Caps -----------------------------------------------------------------------
661658
662−export function CapsCard({ entitlements, owner, busy, error }: { entitlements: Entitlements; owner: boolean; busy: boolean; error?: string }) {
659+export function CapsCard({ entitlements, owner, error }: { entitlements: Entitlements; owner: boolean; error?: string }) {
663660 return (
664661 <Card
665662 id="caps"
697694 </span>
698695 <DollarInput name="issue" label="Issue cap in dollars" defaultValue={String(entitlements.issueCapMicros / 1_000_000)} />
699696 </label>
700− <Button variant="quiet" type="submit" disabled={busy}>
697+ <SubmitButton variant="quiet" match={{ intent: "caps" }} pending="Saving…">
701698 Save caps
702− </Button>
699+ </SubmitButton>
703700 </Form>
704701 )}
705702 <ErrorText>{error}</ErrorText>
713710
714711 // --- Overage -----------------------------------------------------------------------
715712
716−export function OverageCard({ requests, owner, busy, error }: { requests: LimitRequest[]; owner: boolean; busy: boolean; error?: string }) {
713+export function OverageCard({ requests, owner, error }: { requests: LimitRequest[]; owner: boolean; error?: string }) {
717714 const mine = requests.filter((request) => request.kind === "overage");
718715 const waiting = mine.some((request) => request.status === "open");
719716 return (
734731 placeholder="An agent kept retrying a failing check on Tuesday night."
735732 className={`${CONTROL} w-full`}
736733 />
737− <Button variant="quiet" type="submit" disabled={busy}>
734+ <SubmitButton variant="quiet" match={{ intent: "request", kind: "overage" }} pending="Sending…">
738735 Tell g1t
739− </Button>
736+ </SubmitButton>
740737 <ErrorText>{error}</ErrorText>
741738 </Form>
742739 )}
+8−8
4444 type User as Actor,
4545 } from "@g1t/contracts";
4646
47−import { TimeAgo } from "./ui";
47+import { SubmitButton, TimeAgo } from "./ui";
4848 import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
4949 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
5050
157157 <textarea name="text" required rows={4} maxLength={1000} defaultValue={memory.text} className={TEXTAREA} />
158158 <div className="flex items-center justify-between gap-3">
159159 <KindSelect value={memory.kind} />
160− <button type="submit" disabled={fetcher.state !== "idle"} className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50">
160+ <SubmitButton fetcher={fetcher} pending="Keeping…">
161161 Keep
162− </button>
162+ </SubmitButton>
163163 </div>
164164 {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>}
165165 </fetcher.Form>
497497 <input type="hidden" name="intent" value="fix" />
498498 <input type="hidden" name="project" value={project} />
499499 <input type="hidden" name="rule" value={rule.rule} />
500− <button
501− type="submit"
502− disabled={fetcher.state !== "idle"}
500+ <SubmitButton
501+ fetcher={fetcher}
502+ pending="Opening…"
503503 title={`Opens “${rule.fix.title}” and puts an agent on it`}
504504 className="inline-flex shrink-0 items-center gap-1 rounded-md border border-line px-2 py-1 text-xs text-muted hover:border-accent-dim hover:text-fg disabled:opacity-50"
505505 >
506506 <Bot size={12} />
507− {fetcher.state !== "idle" ? "Opening…" : "Fix with an agent"}
508− </button>
507+ Fix with an agent
508+ </SubmitButton>
509509 </fetcher.Form>
510510 )}
511511 </li>
+12−3
2121 HighlightedLine,
2222 } from "../lib/diff";
2323 import { Markdown } from "./markdown";
24−import { Avatar, Button, EmptyState, Textarea, TimeAgo } from "./ui";
24+import { Avatar, Button, EmptyState, SubmitButton, Textarea, TimeAgo } from "./ui";
2525 import { Checkbox } from "./ui/checkbox";
2626
2727 const ROW_STYLES: Record<DiffLine["kind"], string> = {
134134 open: boolean;
135135 onClose: () => void;
136136 }) {
137+ // The box stays, saying it is posting, until the new comment shows above it.
138+ const count = useRef(comments.length);
139+ useEffect(() => {
140+ if (comments.length > count.current) onClose();
141+ count.current = comments.length;
142+ // eslint-disable-next-line react-hooks/exhaustive-deps -- only a new comment closes it
143+ }, [comments.length]);
137144 return (
138145 <div className="max-w-2xl space-y-2">
139146 {comments.map((comment) => (
140147 <LineComment key={comment.id} comment={comment} />
141148 ))}
142149 {open && (
143− <Form method="post" className="space-y-2 font-sans" onSubmit={onClose}>
150+ <Form method="post" className="space-y-2 font-sans">
144151 <input type="hidden" name="action" value="comment" />
145152 <input type="hidden" name="path" value={path} />
146153 <input type="hidden" name="line" value={line} />
147154 <Textarea name="body" rows={3} required autoFocus placeholder={`Comment on line ${line}`} />
148155 <div className="flex gap-2">
149− <Button type="submit">Comment</Button>
156+ <SubmitButton match={{ action: "comment", path, line: String(line) }} pending="Commenting…">
157+ Comment
158+ </SubmitButton>
150159 <Button variant="quiet" type="button" onClick={onClose}>
151160 Cancel
152161 </Button>
+43−22
55
66 import { addressActions, backupChoices } from "../lib/emails";
77 import type { EmailActionData } from "../lib/emails.server";
8−import { Button, ErrorText, Field, Input, Pill, TimeAgo } from "./ui";
8+import { ErrorText, Field, Input, Pill, SubmitButton, TimeAgo } from "./ui";
99 import { SwitchCard } from "./ui/switch";
1010
1111 /** One small form posting one intent about one address. */
12−function AddressButton({ intent, email, label }: { intent: string; email: string; label: string }) {
12+function AddressButton({ intent, email, label, pending }: { intent: string; email: string; label: string; pending: string }) {
1313 return (
1414 <Form method="post">
1515 <input type="hidden" name="intent" value={intent} />
1616 <input type="hidden" name="email" value={email} />
17− <Button variant="quiet" type="submit">
17+ <SubmitButton variant="quiet" pending={pending} match={{ intent, email }}>
1818 {label}
19− </Button>
19+ </SubmitButton>
2020 </Form>
2121 );
2222 }
6464 <Input name="password" type="password" autoComplete="current-password" required autoFocus />
6565 </Field>
6666 </div>
67− <Button type="submit">Confirm</Button>
67+ <SubmitButton pending="Confirming…" match={{ intent: pending.intent }}>
68+ Confirm
69+ </SubmitButton>
6870 </div>
6971 <p className="text-xs text-faint">You will not be asked again for 10 minutes.</p>
7072 </Form>
7880 );
7981 }
8082
81−/** A person's email addresses, in their account settings. */
83+/** The answer to one form's post, under that form: what went wrong, or that it worked. */
84+function Answer({ actionData }: { actionData: EmailActionData | undefined }) {
85+ return (
86+ <>
87+ <ErrorText>{actionData?.emailError}</ErrorText>
88+ {actionData?.emailNotice && (
89+ <p role="status" className="mt-2 text-sm text-muted">
90+ {actionData.emailNotice}
91+ </p>
92+ )}
93+ </>
94+ );
95+}
96+
97+/**
98+ * A person's email addresses, in their account settings. `intent` is the
99+ * form that last posted, so its answer shows beside it.
100+ */
82101 export function EmailsSection({
83102 data,
84103 actionData,
104+ intent,
85105 hasPassword,
86106 }: {
87107 data: AccountEmails | null;
88108 actionData: EmailActionData | undefined;
109+ intent?: string;
89110 hasPassword: boolean;
90111 }) {
91112 if (!data) return null;
113+ const answerFor = (one: string) => (intent === one ? actionData : undefined);
114+ const elsewhere = intent === "backup-email" || intent === "email-privacy";
92115 const backups = backupChoices(data.emails);
93116 const backup = data.emails.find((email) => email.backup);
94117 const full = data.emails.length >= data.limit;
123146 </div>
124147 </div>
125148 <div className="flex shrink-0 flex-wrap gap-2 sm:justify-end">
126− {can.resend && <AddressButton intent="resend-email" email={email.email} label="Resend link" />}
127− {can.makePrimary && <AddressButton intent="primary-email" email={email.email} label="Make primary" />}
128− {can.remove && <AddressButton intent="remove-email" email={email.email} label="Remove" />}
149+ {can.resend && <AddressButton intent="resend-email" email={email.email} label="Resend link" pending="Sending…" />}
150+ {can.makePrimary && <AddressButton intent="primary-email" email={email.email} label="Make primary" pending="Saving…" />}
151+ {can.remove && <AddressButton intent="remove-email" email={email.email} label="Remove" pending="Removing…" />}
129152 </div>
130153 </li>
131154 );
132155 })}
133156 </ul>
134157
135− <Form method="post" className="mt-4">
158+ {/* Keyed on the list, so an address once added leaves the field empty. */}
159+ <Form key={data.emails.length} method="post" className="mt-4">
136160 <input type="hidden" name="intent" value="add-email" />
137161 <div className="flex flex-col gap-3 sm:flex-row sm:items-end">
138162 <div className="grow">
140164 <Input name="email" type="email" autoComplete="email" placeholder="you@example.com" required disabled={full} />
141165 </Field>
142166 </div>
143− <Button type="submit" disabled={full}>
167+ <SubmitButton pending="Adding…" match={{ intent: "add-email" }} disabled={full}>
144168 Add
145− </Button>
169+ </SubmitButton>
146170 </div>
147171 <p className="mt-1.5 text-xs text-faint">
148172 {full ? `You have ${data.limit} addresses, the most an account can have.` : "g1t sends it a link to confirm it."}
149173 </p>
150174 </Form>
151− <ErrorText>{actionData?.emailError}</ErrorText>
152− {actionData?.emailNotice && (
153− <p role="status" className="mt-2 text-sm text-muted">
154− {actionData.emailNotice}
155− </p>
156− )}
175+ <Answer actionData={elsewhere ? undefined : actionData} />
157176
158177 <div className="mt-8">
159178 <h2 className="font-medium">Backup address</h2>
178197 ))}
179198 </select>
180199 </label>
181− <Button variant="quiet" type="submit" disabled={backups.length === 0}>
200+ <SubmitButton variant="quiet" pending="Saving…" match={{ intent: "backup-email" }} disabled={backups.length === 0}>
182201 Save
183− </Button>
202+ </SubmitButton>
184203 </Form>
204+ <Answer actionData={answerFor("backup-email")} />
185205 {backups.length === 0 && (
186206 <p className="mt-2 text-xs text-faint">Add and confirm a second address to choose a backup.</p>
187207 )}
203223 <p className="text-xs text-faint">
204224 Commits g1t makes for you now carry <span className="font-mono [overflow-wrap:anywhere]">{data.commitEmail}</span>.
205225 </p>
206− <Button variant="quiet" type="submit">
226+ <SubmitButton variant="quiet" pending="Saving…" match={{ intent: "email-privacy" }}>
207227 Save
208− </Button>
228+ </SubmitButton>
209229 </div>
210230 </Form>
231+ <Answer actionData={answerFor("email-privacy")} />
211232 </div>
212233 </section>
213234 );
+15−5
22 import { Form } from "react-router";
33 import { siGithub } from "simple-icons";
44
5−import { TimeAgo } from "./ui";
5+import { SubmitButton, TimeAgo } from "./ui";
66
77 /** GitHub's mark (from Simple Icons), for the buttons that go there. */
88 export function GithubMark({ className = "size-4" }: { className?: string }) {
7070 )}
7171 {syncing && (
7272 <Form method="post" className="ml-auto flex items-center gap-3">
73− <button type="submit" name="intent" value="github-sync" className="text-xs text-muted hover:text-fg">
73+ <SubmitButton
74+ name="intent"
75+ value="github-sync"
76+ pending="Syncing…"
77+ className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg disabled:opacity-50"
78+ >
7479 Sync now
75− </button>
76− <button type="submit" name="intent" value="github-stop" className="text-xs text-faint hover:text-danger">
80+ </SubmitButton>
81+ <SubmitButton
82+ name="intent"
83+ value="github-stop"
84+ pending="Stopping…"
85+ className="inline-flex items-center gap-1 text-xs text-faint hover:text-danger disabled:opacity-50"
86+ >
7787 Stop {link.mode === "mirror" ? "mirroring" : "pushing"}
78− </button>
88+ </SubmitButton>
7989 </Form>
8090 )}
8191 {link.lastError && <p className="w-full text-xs text-warn">{link.lastError}</p>}
+8−6
1818
1919 import { formatCap, tri, workflowDomainLine } from "../lib/guardrails";
2020 import { formatCost } from "./agents";
21−import { Button, ErrorText, Input, TimeAgo } from "./ui";
21+import { ErrorText, Input, SubmitButton, TimeAgo } from "./ui";
2222 import { CheckboxOption } from "./ui/checkbox";
2323 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
2424 import { Textarea } from "./ui/textarea";
9797 view: GuardrailsView;
9898 level: "workspace" | "project";
9999 editable: boolean;
100− saving: boolean;
100+ /** Working for a reason the Save button cannot see; it follows its own submission by itself. */
101+ saving?: boolean;
101102 saved: boolean;
102103 error: string | null | undefined;
103104 }) {
111112 const inheritedDeny = level === "project" ? view.inherited.deny : [];
112113 const inheritedWorkflowDomains = level === "project" ? (view.inherited.workflowDomains ?? []) : [];
113114 return (
114− <Form method="post" className="max-w-4xl space-y-8">
115+ // Keyed to the last change, so after a save the fields show what was kept, as it was tidied.
116+ <Form key={own.updatedAt ?? "unset"} method="post" className="max-w-4xl space-y-8">
115117 <fieldset disabled={!editable} className="min-w-0 space-y-8">
116118 <Section
117119 title="Network"
310312
311313 {editable ? (
312314 <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
313− <Button type="submit" disabled={saving}>
314− {saving ? "Saving…" : "Save guardrails"}
315− </Button>
315+ <SubmitButton pending="Saving…" busy={saving}>
316+ Save guardrails
317+ </SubmitButton>
316318 {saved && <span className="text-sm text-muted">Saved. Runs that start from now on get these.</span>}
317319 <ErrorText>{error}</ErrorText>
318320 {own.updatedBy && own.updatedAt && !saved && !error && (
+8−5
33
44 import type { Invite, InvitesOverview } from "@g1t/contracts";
55
6−import { Button, CopyLine, ErrorText, Field, Input, TimeAgo } from "./ui";
6+import { CopyLine, ErrorText, Field, Input, SubmitButton, TimeAgo } from "./ui";
77 import { inviteFor, inviteLink, inviteState, moreInvitesMailto, remainingLine } from "../lib/invites";
88
99 const TONE: Record<"pending" | "done" | "dead", string> = {
2323 <Form method="post" className="ml-auto">
2424 <input type="hidden" name="intent" value="revoke-invite" />
2525 <input type="hidden" name="id" value={invite.id} />
26− <Button variant="quiet" type="submit">
26+ <SubmitButton variant="quiet" match={{ intent: "revoke-invite", id: invite.id }} pending="Revoking…">
2727 Revoke
28− </Button>
28+ </SubmitButton>
2929 </Form>
3030 )}
3131 </div>
112112 </p>
113113 </div>
114114 ) : (
115− <Form method="post" className="mt-4 space-y-3">
115+ // Empty again once the invite is made; kept as filled in when it failed.
116+ <Form method="post" key={created?.id ?? ""} className="mt-4 space-y-3">
116117 <input type="hidden" name="intent" value="create-invite" />
117118 <div className="flex flex-col gap-3 sm:flex-row sm:items-end">
118119 <div className="grow">
138139 </select>
139140 </label>
140141 )}
141− <Button type="submit">Create invite</Button>
142+ <SubmitButton match={{ intent: "create-invite" }} pending="Creating…">
143+ Create invite
144+ </SubmitButton>
142145 </div>
143146 <p className="text-xs text-faint">
144147 With an email, the invite is sent there and only that address can use it.
+8−15
99
1010 import { MEMORY_KINDS, type Memory, type MemoryKind, type MemoryScope, type Result, type User as Actor } from "@g1t/contracts";
1111
12−import { TimeAgo } from "./ui";
12+import { SubmitButton, TimeAgo } from "./ui";
1313 import {
1414 AlertDialog,
1515 AlertDialogAction,
127127 <textarea name="text" required rows={4} maxLength={1000} defaultValue={memory.text} className={TEXTAREA} />
128128 <div className="flex items-center justify-between gap-3">
129129 <KindSelect value={memory.kind} />
130− <button
131− type="submit"
132− disabled={fetcher.state !== "idle"}
133− className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50"
134− >
130+ <SubmitButton fetcher={fetcher} pending="Saving…">
135131 Save
136− </button>
132+ </SubmitButton>
137133 </div>
138134 {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>}
139135 </fetcher.Form>
176172 aria-label={pinned ? "Unpin" : "Pin"}
177173 title={pinned ? "Unpin" : "Pin: given to every agent first"}
178174 onClick={() => fetcher.submit({ intent: "update", id: memory.id, pinned: String(!pinned) }, { method: "post", action })}
179− className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-fg"
175+ disabled={fetcher.state !== "idle"}
176+ className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-fg disabled:opacity-50"
180177 >
181178 {pinned ? <PinOff size={13} /> : <Pin size={13} />}
182179 </button>
252249 Pin it
253250 </label>
254251 <span className="grow" />
255− <button
256− type="submit"
257− disabled={fetcher.state !== "idle"}
258− className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50"
259− >
260− {fetcher.state !== "idle" ? "Adding…" : "Add"}
261− </button>
252+ <SubmitButton fetcher={fetcher} pending="Adding…">
253+ Add
254+ </SubmitButton>
262255 </div>
263256 {fetcher.data?.error && <p className="mt-2 text-sm text-danger">{fetcher.data.error}</p>}
264257 <p className="mt-2 text-xs text-faint">
+8−3
2828 import { catchUpPhase, catchUpRun, catchUpTitle, catchUpWhy } from "../lib/catch-up";
2929 import { duration } from "./actions";
3030 import { Elapsed, useRuns } from "./agents";
31−import { Button, CopyLine, ErrorText, TimeAgo } from "./ui";
31+import { Button, CopyLine, ErrorText, SubmitButton, TimeAgo } from "./ui";
3232 import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
3333
3434 // --- Output ---------------------------------------------------------------
408408 <Form method="post" key={status.context}>
409409 <input type="hidden" name="action" value="rerun-workflow" />
410410 <input type="hidden" name="run" value={runIdOf(status) ?? ""} />
411− <Button variant="quiet" type="submit" title={`Re-run the failed jobs of ${status.context}`}>
411+ <SubmitButton
412+ variant="quiet"
413+ match={{ action: "rerun-workflow", run: runIdOf(status) ?? "" }}
414+ pending="Re-running…"
415+ title={`Re-run the failed jobs of ${status.context}`}
416+ >
412417 <RotateCw size={13} />
413418 Re-run failed jobs{failedRuns.length > 1 ? ` of ${status.context}` : ""}
414− </Button>
419+ </SubmitButton>
415420 </Form>
416421 ))}
417422 </div>
+11−10
3333 import { useLiveRefresh } from "./agents";
3434 import { Unavailable } from "./mission";
3535 import { TokenUsagePanel } from "./token-usage";
36−import { Avatar, TimeAgo } from "./ui";
36+import { Avatar, SubmitButton, TimeAgo } from "./ui";
3737 import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./ui/dropdown-menu";
3838 import type { Loaded } from "../routes/home";
3939
117117 );
118118 }
119119
120−/** A form that acts on a pull request from here, saying so before the page catches up. */
120+/**
121+ * A form that acts on a pull request from here: working until the action
122+ * has answered and the page has reloaded what it changed, then done, or
123+ * the error beside it.
124+ */
121125 function QuickForm({ quick, variant = "quiet" }: { quick: QuickAction; variant?: "quiet" | "accent" }) {
122126 const fetcher = useFetcher<{ error?: string } | null>();
123127 const [sent, setSent] = useState(false);
124− const busy = fetcher.state !== "idle";
125− // Shown as done the moment it is sent, and undone if it fails: the page
126− // reloads behind it, which takes longer than the action.
127− const done = sent && (busy || !fetcher.data?.error);
128+ const done = sent && fetcher.state === "idle" && !fetcher.data?.error;
128129 return (
129130 <fetcher.Form method="post" action={quick.to} onSubmit={() => setSent(true)} className="contents">
130131 {Object.entries(quick.fields).map(([name, value]) => (
131132 <input key={name} type="hidden" name={name} value={value} />
132133 ))}
133− <button
134− type="submit"
135− disabled={busy || done}
134+ <SubmitButton
135+ fetcher={fetcher}
136+ disabled={done}
136137 className={cn(
137138 "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-default",
138139 variant === "accent"
143144 >
144145 {done ? <Check size={14} /> : null}
145146 {done ? quick.done : quick.label}
146− </button>
147+ </SubmitButton>
147148 {fetcher.data?.error && <span className="basis-full text-xs text-danger">{fetcher.data.error}</span>}
148149 </fetcher.Form>
149150 );
+3−5
99
1010 import { type Connection, MODEL_TASKS, type ModelRoute, type ModelTask, type Provider, PROVIDERS } from "@g1t/contracts";
1111
12−import { Button, Field, Input } from "./ui";
12+import { Field, Input, SubmitButton } from "./ui";
1313 import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select";
1414
1515 type Group = "labs" | "platforms" | "any";
361361 hostedOpen,
362362 marginPercent,
363363 owner,
364− busy,
365364 saved,
366365 }: {
367366 connections: Connection[];
369368 hostedOpen: boolean;
370369 marginPercent: number;
371370 owner: boolean;
372− busy: boolean;
373371 saved: boolean;
374372 }) {
375373 const fallback: Choice = hostedOpen || connections.length === 0 ? { target: "g1t", model: "" } : { target: connections[0].id, model: "" };
477475 </ul>
478476 {owner && (
479477 <div className="flex items-center gap-3 border-t border-line px-4 py-3">
480− <Button type="submit" variant="quiet" disabled={busy}>
478+ <SubmitButton variant="quiet" match={{ intent: "routes" }} pending="Saving…">
481479 Save routing
482− </Button>
480+ </SubmitButton>
483481 {saved && <span className="text-sm text-accent">Saved. The next runs use it.</span>}
484482 </div>
485483 )}
+6−3
66 import { useSignUpCopy } from "../lib/registration";
77 import { missingKind, notFoundCopy } from "../lib/not-found";
88 import { Pixel404 } from "./logo";
9−import { ButtonLink } from "./ui";
9+import { ButtonLink, SubmitButton } from "./ui";
1010
1111 /**
1212 * The page for anything someone cannot see. It reads only the kind of thing
7474 className="mt-3 text-xs text-faint"
7575 >
7676 Signed in as <span className="font-mono text-muted">@{copy.signedInAs}</span> ·{" "}
77− <button type="submit" className="text-muted underline-offset-4 hover:text-fg hover:underline">
77+ <SubmitButton
78+ pending="Signing out…"
79+ className="inline-flex items-center gap-1 text-muted underline-offset-4 hover:text-fg hover:underline disabled:opacity-50"
80+ >
7881 Switch account
79− </button>
82+ </SubmitButton>
8083 </Form>
8184 )}
8285 </main>
+6−7
11 import { Check } from "lucide-react";
22 import { useState } from "react";
3−import { Form, Link, useNavigation } from "react-router";
3+import { Form, Link } from "react-router";
44
55 import { PROFILE_LIMITS, type Profile } from "@g1t/contracts";
66
7−import { Button } from "./ui";
7+import { SubmitButton, usePending } from "./ui";
88 import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field";
99 import { Input } from "./ui/input";
1010 import { Textarea } from "./ui/textarea";
2424 error?: string;
2525 saved: boolean;
2626 }) {
27− const navigation = useNavigation();
28− const busy = navigation.state === "submitting" && navigation.formData?.get("intent") === "profile";
27+ const busy = usePending({ intent: "profile" });
2928 const [bio, setBio] = useState(profile?.bio ?? "");
3029 return (
3130 <section id="profile" className="scroll-mt-20">
9998 <FieldDescription>An https:// address.</FieldDescription>
10099 </Field>
101100 <div className="flex flex-wrap items-center gap-3 sm:col-span-2">
102− <Button type="submit" disabled={busy}>
103− {busy ? "Saving…" : "Save profile"}
104− </Button>
101+ <SubmitButton pending="Saving…" match={{ intent: "profile" }}>
102+ Save profile
103+ </SubmitButton>
105104 {saved && !busy && !error && (
106105 <span className="inline-flex items-center gap-1.5 text-sm text-muted">
107106 <Check size={14} className="text-accent" />
+16−11
11 import { Archive } from "lucide-react";
2−import { type ReactNode, useId, useState } from "react";
3−import { Form, Link, useNavigation } from "react-router";
2+import { type ReactNode, useEffect, useId, useRef, useState } from "react";
3+import { Form, Link } from "react-router";
44
5−import { Button, ErrorText } from "./ui";
5+import { ErrorText, SubmitButton, usePending } from "./ui";
66 import {
77 AlertDialog,
88 AlertDialogCancel,
5656 }) {
5757 const [open, setOpen] = useState(Boolean(error));
5858 const [typed, setTyped] = useState("");
59− const navigation = useNavigation();
6059 const id = useId();
61− const posting =
62− navigation.state !== "idle" &&
63− navigation.formData?.get("intent") === intent &&
64− Object.entries(fields ?? {}).every(([k, v]) => navigation.formData?.get(k) === v);
60+ const posting = usePending({ intent, ...fields });
61+ // Done without an error, it closes: the page behind it now shows the change.
62+ const wasPosting = useRef(false);
63+ useEffect(() => {
64+ if (wasPosting.current && !posting && !error) {
65+ setOpen(false);
66+ setTyped("");
67+ }
68+ wasPosting.current = posting;
69+ }, [posting, error]);
6570 const ready = confirm == null || confirmsName(typed, confirm);
6671 return (
6772 <>
104109 <ErrorText>{error}</ErrorText>
105110 <AlertDialogFooter>
106111 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
107− <Button type="submit" variant={danger ? "danger" : "primary"} disabled={!ready || posting}>
108− {posting ? busy : submit}
109− </Button>
112+ <SubmitButton variant={danger ? "danger" : "primary"} disabled={!ready} match={{ intent, ...fields }} pending={busy}>
113+ {submit}
114+ </SubmitButton>
110115 </AlertDialogFooter>
111116 </Form>
112117 </AlertDialogContent>
+43−26
11 import { Bot, Boxes, Cpu, Plus, ServerCog, ShieldAlert, Trash2 } from "lucide-react";
2−import { useState } from "react";
3−import { Form, Link, useNavigation } from "react-router";
2+import { useEffect, useState } from "react";
3+import { Form, Link } from "react-router";
44
55 import { type RegistrationToken, RUNNER_DOWNLOADS, RUNNER_FILES, RUNNER_IMAGE, type Runner, type RunnerGroup } from "@g1t/contracts";
66
77 import type { RunnersAction, RunnersData } from "../lib/runners.server";
8−import { Button, CopyLine, EmptyState, ErrorText, Field, Input, Pill, TimeAgo } from "./ui";
8+import { Button, CopyLine, EmptyState, ErrorText, Field, Input, Pill, SubmitButton, TimeAgo } from "./ui";
99 import { Switch } from "./ui/switch";
1010 import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
1111
3434
3535 function RunnerRow({ runner, manage, base }: { runner: Runner; manage: boolean; base: (repo: string) => string }) {
3636 const status = STATUS[runner.status];
37− const busy = useNavigation().state === "submitting";
3837 return (
3938 <li className="flex flex-wrap items-start gap-x-4 gap-y-2 px-4 py-3">
4039 <span className={`mt-1.5 size-2 shrink-0 rounded-full ${status.dot}`} aria-hidden />
8483 <input type="hidden" name="intent" value="remove" />
8584 <input type="hidden" name="id" value={runner.id} />
8685 <input type="hidden" name="name" value={runner.name} />
87− <button
88− type="submit"
89− disabled={busy}
86+ <SubmitButton
87+ icon
88+ match={{ intent: "remove", id: runner.id }}
9089 aria-label={`Remove ${runner.name}`}
91− className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger"
90+ className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger disabled:opacity-50"
9291 >
9392 <Trash2 size={15} />
94− </button>
93+ </SubmitButton>
9594 </Form>
9695 )}
9796 </li>
146145 function NewRunner({ token, groups, repoScoped }: { token: RegistrationToken | null; groups: RunnerGroup[]; repoScoped: boolean }) {
147146 const [platform, setPlatform] = useState<Platform>("linux");
148147 const [arch, setArch] = useState<"x64" | "arm64">("x64");
149− const busy = useNavigation().state === "submitting";
150148 return (
151149 <div className="space-y-4 rounded-xl border border-line bg-surface p-4">
152150 {!token ? (
163161 </select>
164162 </Field>
165163 )}
166− <Button type="submit" disabled={busy}>
164+ <SubmitButton match={{ intent: "token" }} pending="Making a token…">
167165 <Plus size={15} />
168166 New runner
169− </Button>
167+ </SubmitButton>
170168 <p className="basis-full text-xs text-faint">Makes a registration token that lasts an hour and registers any number of runners. It is shown once.</p>
171169 </Form>
172170 ) : (
239237 );
240238 }
241239
242−function Groups({ groups, repositories, manage }: { groups: RunnerGroup[]; repositories: string[]; manage: boolean }) {
240+function Groups({
241+ groups,
242+ repositories,
243+ manage,
244+ action,
245+}: {
246+ groups: RunnerGroup[];
247+ repositories: string[];
248+ manage: boolean;
249+ action: RunnersAction | undefined;
250+}) {
243251 const [editing, setEditing] = useState<string | null>(null);
252+ // A group saved closes its form: the list above now shows it as saved.
253+ useEffect(() => {
254+ if (action?.notice) setEditing(null);
255+ }, [action]);
244256 return (
245257 <div className="space-y-3">
246258 <ul className="divide-y divide-line rounded-xl border border-line bg-surface">
262274 <Form method="post" onSubmit={(event) => { if (!confirm(`Delete ${group.name}? Its runners join the default group.`)) event.preventDefault(); }}>
263275 <input type="hidden" name="intent" value="delete-group" />
264276 <input type="hidden" name="id" value={group.id} />
265− <Button type="submit" variant="quiet">
277+ <SubmitButton variant="quiet" match={{ intent: "delete-group", id: group.id }} pending="Deleting…">
266278 Delete
267− </Button>
279+ </SubmitButton>
268280 </Form>
269281 )}
270282 </span>
286298
287299 function GroupForm({ group, repositories }: { group: RunnerGroup | null; repositories: string[] }) {
288300 const [reach, setReach] = useState(group && group.repositories.length > 0 ? "some" : "all");
289− const busy = useNavigation().state === "submitting";
290301 return (
291302 <Form method="post" className="mt-3 space-y-3 rounded-lg border border-line bg-bg p-3">
292303 <input type="hidden" name="intent" value="group" />
315326 </div>
316327 )}
317328 </fieldset>
318− <Button type="submit" disabled={busy}>
329+ <SubmitButton match={{ intent: "group", id: group?.id }} pending="Saving…">
319330 Save group
320− </Button>
331+ </SubmitButton>
321332 </Form>
322333 );
323334 }
324335
325336 function Settings({ data, manage, scope }: { data: RunnersData; manage: boolean; scope: "workspace" | "project" }) {
326337 const settings = data.settings;
327− const busy = useNavigation().state === "submitting";
328338 const [agents, setAgents] = useState(settings?.agentsOnSelfHosted ?? false);
329339 const [forks, setForks] = useState(settings?.forkPullRequests ?? false);
330340 if (!settings) return null;
341+ // The intent comes from the button pressed: a hidden one before it would
342+ // win the form's first "intent", and Follow the workspace would only save.
331343 return (
332344 <Form method="post" className="space-y-4">
333− <input type="hidden" name="intent" value="settings" />
334345 {scope === "project" && settings.inherited && (
335346 <p className="text-sm text-muted">These are the workspace&apos;s settings. Saving here gives this project its own.</p>
336347 )}
369380 </p>
370381 {manage && (
371382 <div className="flex flex-wrap gap-3">
372− <Button type="submit" disabled={busy}>
383+ <SubmitButton name="intent" value="settings" pending="Saving…">
373384 Save
374− </Button>
385+ </SubmitButton>
375386 {scope === "project" && !settings.inherited && (
376− <Button type="submit" name="intent" value="inherit" variant="quiet" disabled={busy}>
387+ <SubmitButton name="intent" value="inherit" variant="quiet" pending="Following…">
377388 Follow the workspace
378− </Button>
389+ </SubmitButton>
379390 )}
380391 </div>
381392 )}
444455 title="Groups"
445456 about="Which repositories may use which runners. A runner joins the default group, every repository, unless its token names another."
446457 >
447− <Groups groups={data.groups} repositories={data.repositories} manage={manage} />
458+ <Groups groups={data.groups} repositories={data.repositories} manage={manage} action={action} />
448459 </Section>
449460 )}
450461 <Section
452463 title="Where work runs"
453464 about={scope === "workspace" ? "For every project in the workspace, unless a project says otherwise." : "For this project."}
454465 >
455− <Settings data={data} manage={manage} scope={scope} />
466+ {/* Keyed to what is saved, so following the workspace again shows its settings, not the switches as they were. */}
467+ <Settings
468+ key={data.settings ? `${data.settings.inherited}:${data.settings.agentsOnSelfHosted}:${data.settings.forkPullRequests}:${data.settings.agentLabels.join(",")}` : "none"}
469+ data={data}
470+ manage={manage}
471+ scope={scope}
472+ />
456473 </Section>
457474 </div>
458475 );
+11−12
77 */
88 import { Lock, Pencil, Plus, Search, SlidersHorizontal, Trash2, X } from "lucide-react";
99 import { useMemo, useState } from "react";
10−import { Form, Link, useLocation, useNavigation } from "react-router";
10+import { Form, Link, useLocation } from "react-router";
1111
1212 import type { Setting } from "@g1t/contracts";
1313
1414 import type { SecretsAction, SecretsData } from "../lib/secrets.server";
15−import { Button, ButtonLink, EmptyState, ErrorText, TimeAgo } from "./ui";
15+import { ButtonLink, EmptyState, ErrorText, SubmitButton, TimeAgo } from "./ui";
1616 import { CheckboxOption } from "./ui/checkbox";
1717 import { RadioCard, RadioGroup, RadioOption } from "./ui/radio-group";
1818 import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select";
150150 </div>
151151
152152 {manage && (adding || row) && (
153− <Drawer row={row} scope={scope} projects={data.projects} error={action?.error} />
153+ // Keyed to the row, so going from one row's edit to another's starts from that row.
154+ <Drawer key={row?.id ?? "add"} row={row} scope={scope} projects={data.projects} error={action?.error} />
154155 )}
155156 </div>
156157 );
157158 }
158159
159160 function Row({ row, inherited, manage }: { row: Setting; inherited: boolean; manage: boolean }) {
160− const busy = useNavigation().state === "submitting";
161161 const secret = row.kind === "secret";
162162 return (
163163 <li className="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_auto] items-center gap-x-4 gap-y-1 border-t border-line px-4 py-3 text-sm first:border-t-0 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_minmax(0,1fr)_6rem_6rem_auto]">
200200 <input type="hidden" name="intent" value="delete" />
201201 <input type="hidden" name="id" value={row.id} />
202202 <input type="hidden" name="name" value={row.name} />
203− <button
204− type="submit"
205− disabled={busy}
203+ <SubmitButton
204+ icon
205+ match={{ intent: "delete", id: row.id }}
206206 aria-label={`Remove ${row.name}`}
207− className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger"
207+ className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger disabled:opacity-50"
208208 >
209209 <Trash2 size={14} />
210− </button>
210+ </SubmitButton>
211211 </Form>
212212 </>
213213 )}
229229 projects: string[];
230230 error: string | undefined;
231231 }) {
232− const busy = useNavigation().state === "submitting";
233232 const editing = !!row;
234233 const [type, setType] = useState<"secret" | "config">(row?.kind === "variable" ? "config" : "secret");
235234 const [some, setSome] = useState(!!row && row.environments.length > 0);
403402 </div>
404403 <div className="flex items-center justify-between gap-4 border-t border-line px-6 py-4">
405404 <p className="text-xs text-faint">{editing ? "" : "Paste .env contents into Key to add many."}</p>
406− <Button type="submit" disabled={busy}>
405+ <SubmitButton match={{ intent: "save" }} pending="Saving…">
407406 Save
408− </Button>
407+ </SubmitButton>
409408 </div>
410409 </Form>
411410 </div>
+5−5
44 import type { Entitlements } from "@g1t/contracts";
55
66 import { dollars } from "../lib/billing";
7−import { Button } from "./ui";
7+import { SubmitButton } from "./ui";
88
99 /**
1010 * Compute paused by a spend spike, with the owners' choice: keep going for
4444 {spike && owner ? (
4545 <Form method="post" action={`/${slug}/-/billing`} className={compact ? "flex gap-2" : "mt-4 flex flex-wrap gap-2"}>
4646 <input type="hidden" name="intent" value="spike" />
47− <Button variant="accent" type="submit" name="decision" value="keep">
47+ <SubmitButton variant="accent" name="decision" value="keep" match={{ intent: "spike" }} pending="Starting…">
4848 <Play size={14} />
4949 Keep going
50− </Button>
51− <Button variant="quiet" type="submit" name="decision" value="stop">
50+ </SubmitButton>
51+ <SubmitButton variant="quiet" name="decision" value="stop" match={{ intent: "spike" }} pending="Stopping…">
5252 <OctagonX size={14} />
5353 Stop
54− </Button>
54+ </SubmitButton>
5555 </Form>
5656 ) : spike ? (
5757 <p className={compact ? "text-xs text-muted" : "mt-3 text-sm text-muted"}>An owner chooses Keep going or Stop on Billing.</p>
+14−6
184184 const [open, setOpen] = useState(false);
185185 const [entries, setEntries] = useState<LedgerEntry[]>([]);
186186 const [done, setDone] = useState(false);
187− const fetcher = useFetcher<LedgerEntry[]>();
187+ const fetcher = useFetcher<LedgerEntry[] | { error: string }>();
188188 const [seen, setSeen] = useState<LedgerEntry[] | undefined>(undefined);
189+ const failed = fetcher.data && !Array.isArray(fetcher.data) ? fetcher.data.error : null;
189190
190− // Each page that arrives is added once.
191− if (fetcher.data && fetcher.data !== seen) {
192− setSeen(fetcher.data);
193− setEntries((before) => [...before, ...fetcher.data!]);
194− if (fetcher.data.length < 50) setDone(true);
191+ // Each page that arrives is added once. A page can come again: the page
192+ // reloads what fetchers loaded after every change made on it, so an entry
193+ // already listed is not listed twice.
194+ if (Array.isArray(fetcher.data) && fetcher.data !== seen) {
195+ const page = fetcher.data;
196+ setSeen(page);
197+ setEntries((before) => {
198+ const listed = new Set(before.map((entry) => entry.id));
199+ return [...before, ...page.filter((entry) => !listed.has(entry.id))];
200+ });
201+ if (page.length < 50) setDone(true);
195202 }
196203
197204 const load = (before: string | null) => {
253260 </li>
254261 ))}
255262 </ul>
263+ {failed && fetcher.state === "idle" && <p className="py-2 pl-11 text-xs text-danger">{failed}</p>}
256264 {fetcher.state === "loading" && (
257265 <div aria-busy="true" className="pl-8">
258266 <SkeletonRows rows={3} rowClassName="h-12" />
+67−2
1−import { Check, Copy } from "lucide-react";
1+import { Check, Copy, LoaderCircle } from "lucide-react";
22 import { type ComponentProps, type ReactNode, useState } from "react";
3−import { Link, type LinkProps, NavLink, useLocation } from "react-router";
3+import { Link, type LinkProps, NavLink, useLocation, useNavigation } from "react-router";
44
55 import { isWaitingMessage, linkPaths } from "../../lib/compute";
6+import { type Submission, isPending } from "../../lib/pending";
67 import { Mark } from "../logo";
78
89 export function Field({
124125 );
125126 }
126127
128+/**
129+ * Whether the submission `fields` names is still working (lib/pending.ts):
130+ * the page's own navigation, or `fetcher`'s when the form is a fetcher's.
131+ */
132+export function usePending(fields?: Record<string, string | null | undefined>, fetcher?: Submission): boolean {
133+ const navigation = useNavigation();
134+ return isPending(fetcher ?? navigation, fields);
135+}
136+
137+/**
138+ * A form's submit button that says it is working: turned off, with a
139+ * spinner and `pending` ("Saving…") in place of its words, from the moment
140+ * it is pressed until the page has loaded what it changed. Its own
141+ * `name`/`value` say which submission is its; `match` names it otherwise,
142+ * such as the form's hidden `intent`. `fetcher` when the form is a
143+ * fetcher's. `className` replaces the button look, for icon buttons, and
144+ * `icon` says the spinner takes the place of everything inside it. Words
145+ * with a leading icon should pass `pending`, so the spinner replaces both.
146+ */
147+export function SubmitButton({
148+ variant = "primary",
149+ pending,
150+ match,
151+ fetcher,
152+ busy,
153+ icon,
154+ disabled,
155+ className,
156+ children,
157+ ...props
158+}: Omit<ComponentProps<"button">, "type"> & {
159+ variant?: Variant;
160+ /** The words while it works, such as "Saving…"; its own words when absent. */
161+ pending?: ReactNode;
162+ match?: Record<string, string | null | undefined>;
163+ fetcher?: Submission;
164+ /** Working for a reason this button cannot see by itself. */
165+ busy?: boolean;
166+ /** An icon button: while it works, the spinner is all it shows. */
167+ icon?: boolean;
168+}) {
169+ const own =
170+ typeof props.name === "string" && props.value != null ? { [props.name]: String(props.value) } : undefined;
171+ const working = usePending({ ...own, ...match }, fetcher) || Boolean(busy);
172+ return (
173+ <button
174+ {...props}
175+ type="submit"
176+ disabled={disabled || working}
177+ aria-busy={working || undefined}
178+ className={className ?? `${BUTTON_BASE} ${BUTTON_VARIANTS[variant]}`}
179+ >
180+ {working ? (
181+ <>
182+ <LoaderCircle size={14} aria-hidden="true" className="shrink-0 animate-spin" />
183+ {icon ? null : (pending ?? children)}
184+ </>
185+ ) : (
186+ children
187+ )}
188+ </button>
189+ );
190+}
191+
127192 /** A link that looks like a button. */
128193 export function ButtonLink({
129194 variant = "primary",
+22−19
44 */
55 import { ChevronRight, Pause, Play, RotateCw, Send, Trash2 } from "lucide-react";
66 import { useState } from "react";
7−import { Form, Link, useNavigation } from "react-router";
7+import { Form, Link } from "react-router";
88
99 import { EVENT_TYPES, type Hook, type HookDelivery } from "@g1t/contracts";
1010
1111 import type { WebhooksAction, WebhooksData } from "../lib/webhooks.server";
12−import { Button, CopyLine, EmptyState, ErrorText, Field, Input, TimeAgo } from "./ui";
12+import { CopyLine, EmptyState, ErrorText, Field, Input, SubmitButton, TimeAgo } from "./ui";
1313 import { CheckboxOption } from "./ui/checkbox";
1414 import { RadioGroup, RadioOption } from "./ui/radio-group";
1515
5151 }
5252
5353 function DeliveryRow({ delivery, manage }: { delivery: HookDelivery; manage: boolean }) {
54− const busy = useNavigation().state === "submitting";
5554 return (
5655 <details className="group border-t border-line first:border-t-0">
5756 <summary className="flex cursor-pointer list-none items-center gap-3 px-4 py-2.5 text-sm hover:bg-raised/40">
9089 <Form method="post" className="mt-3">
9190 <input type="hidden" name="intent" value="redeliver" />
9291 <input type="hidden" name="delivery" value={delivery.id} />
93− <Button type="submit" variant="quiet" disabled={busy}>
92+ <SubmitButton
93+ variant="quiet"
94+ match={{ intent: "redeliver", delivery: delivery.id }}
95+ pending="Redelivering…"
96+ >
9497 <RotateCw size={14} />
9598 Redeliver
96− </Button>
99+ </SubmitButton>
97100 </Form>
98101 )}
99102 </div>
103106 }
104107
105108 function HookRow({ hook, open, deliveries, manage }: { hook: Hook; open: boolean; deliveries: HookDelivery[]; manage: boolean }) {
106− const busy = useNavigation().state === "submitting";
107109 return (
108110 <li className="border-t border-line first:border-t-0">
109111 <div className="flex items-center gap-3 px-4 py-3">
132134 {manage && (
133135 <Form method="post" className="flex shrink-0 gap-1">
134136 <input type="hidden" name="id" value={hook.id} />
135− <IconButton intent="ping" label="Send a ping" disabled={busy}>
137+ <IconButton intent="ping" id={hook.id} label="Send a ping">
136138 <Send size={14} />
137139 </IconButton>
138140 <input type="hidden" name="active" value={hook.active ? "false" : "true"} />
139− <IconButton intent="toggle" label={hook.active ? "Pause" : "Resume"} disabled={busy}>
141+ <IconButton intent="toggle" id={hook.id} label={hook.active ? "Pause" : "Resume"}>
140142 {hook.active ? <Pause size={14} /> : <Play size={14} />}
141143 </IconButton>
142− <IconButton intent="delete" label="Delete" disabled={busy}>
144+ <IconButton intent="delete" id={hook.id} label="Delete">
143145 <Trash2 size={14} />
144146 </IconButton>
145147 </Form>
158160 );
159161 }
160162
161−function IconButton({ intent, label, disabled, children }: { intent: string; label: string; disabled: boolean; children: React.ReactNode }) {
163+/** One webhook's action, as an icon: a spinner in its place while that action goes. */
164+function IconButton({ intent, id, label, children }: { intent: string; id: string; label: string; children: React.ReactNode }) {
162165 return (
163− <button
164− type="submit"
166+ <SubmitButton
167+ icon
165168 name="intent"
166169 value={intent}
170+ match={{ id }}
167171 title={label}
168172 aria-label={label}
169− disabled={disabled}
170173 className="rounded-md p-2 text-muted transition-colors hover:bg-raised hover:text-fg disabled:opacity-50"
171174 >
172175 {children}
173− </button>
176+ </SubmitButton>
174177 );
175178 }
176179
177180 function AddWebhook() {
178− const busy = useNavigation().state === "submitting";
179181 const [which, setWhich] = useState<"all" | "some">("all");
180182 return (
181183 <Form method="post" className="space-y-4 rounded-xl border border-line bg-surface p-5">
223225 <Field label="Secret" hint="Optional. Deliveries are signed with it. Leave it empty and g1t makes one, shown once.">
224226 <Input name="secret" type="password" autoComplete="off" />
225227 </Field>
226− <Button type="submit" disabled={busy}>
227− {busy ? "Adding…" : "Add webhook"}
228− </Button>
228+ <SubmitButton match={{ intent: "create" }} pending="Adding…">
229+ Add webhook
230+ </SubmitButton>
229231 </Form>
230232 );
231233 }
291293 </ul>
292294 )}
293295
294− {manage && <AddWebhook />}
296+ {/* Keyed to the webhook just added, so the form starts empty for the next. */}
297+ {manage && <AddWebhook key={created?.hook.id ?? "new"} />}
295298 <p className="text-xs text-faint">
296299 Each delivery is an HTTPS POST of JSON with <code>X-G1t-Event</code>, <code>X-G1t-Delivery</code> and{" "}
297300 <code>X-G1t-Signature-256</code>. One that is not answered with a 2xx is tried again after 1 minute, 5 minutes,
+15−6
88 import { Markdown } from "./markdown";
99 import { IssueIcon, PullIcon } from "./work-icons";
1010 import { MentionTextarea } from "./mention-textarea";
11−import { Avatar, Button, TimeAgo } from "./ui";
11+import { Avatar, SubmitButton, TimeAgo } from "./ui";
1212 import { CheckboxOption } from "./ui/checkbox";
1313
1414 export { IssueIcon, PullIcon };
412412 }
413413 />
414414 <div className="flex flex-wrap gap-2">
415− <Button type="submit">Comment</Button>
415+ {/* A plain comment's verdict is none the action knows, so each button tells its own post apart. */}
416+ <SubmitButton name="verdict" value="comment" match={{ action: "comment" }} pending="Commenting…">
417+ Comment
418+ </SubmitButton>
416419 {review?.canJudge && (
417420 <>
418− <Button variant="quiet" type="submit" name="verdict" value="approve">
421+ <SubmitButton variant="quiet" name="verdict" value="approve" match={{ action: "comment" }} pending="Approving…">
419422 <CircleCheck size={14} className="text-accent" />
420423 Approve
421− </Button>
422− <Button variant="quiet" type="submit" name="verdict" value="request_changes">
424+ </SubmitButton>
425+ <SubmitButton
426+ variant="quiet"
427+ name="verdict"
428+ value="request_changes"
429+ match={{ action: "comment" }}
430+ pending="Requesting changes…"
431+ >
423432 <CircleSlash size={14} className="text-danger" />
424433 Request changes
425− </Button>
434+ </SubmitButton>
426435 </>
427436 )}
428437 </div>
+38−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { isPending } from "./pending.ts";
5+
6+const posted = (entries: Record<string, string>) => {
7+ const form = new FormData();
8+ for (const [name, value] of Object.entries(entries)) form.set(name, value);
9+ return form;
10+};
11+
12+test("nothing is pending while the page is idle", () => {
13+ assert.equal(isPending({ state: "idle" }), false);
14+ assert.equal(isPending({ state: "idle", formMethod: "POST", formData: posted({ intent: "save" }) }, { intent: "save" }), false);
15+});
16+
17+test("a post is pending while it is sent and while the page reloads after it", () => {
18+ const formData = posted({ intent: "save" });
19+ assert.equal(isPending({ state: "submitting", formMethod: "POST", formData }), true);
20+ assert.equal(isPending({ state: "loading", formMethod: "POST", formData }), true);
21+ assert.equal(isPending({ state: "loading", formMethod: "post", formData }, { intent: "save" }), true);
22+});
23+
24+test("following a link, or a GET form, is not a pending write", () => {
25+ assert.equal(isPending({ state: "loading" }), false);
26+ assert.equal(isPending({ state: "loading", formMethod: "GET", formData: posted({ q: "x" }) }), false);
27+ assert.equal(isPending({ state: "submitting", formMethod: "GET", formData: posted({ q: "x" }) }), false);
28+});
29+
30+test("only the submission that matches is pending, so one row's button does not speak for another's", () => {
31+ const formData = posted({ intent: "delete", id: "hook_1" });
32+ const sending = { state: "submitting" as const, formMethod: "POST", formData };
33+ assert.equal(isPending(sending, { intent: "delete", id: "hook_1" }), true);
34+ assert.equal(isPending(sending, { intent: "delete", id: "hook_2" }), false);
35+ assert.equal(isPending(sending, { intent: "ping" }), false);
36+ // A field left out (undefined) does not have to match.
37+ assert.equal(isPending(sending, { intent: "delete", id: undefined }), true);
38+});
+24−0
1+/**
2+ * Whether a button's submission is still working: posted and not yet
3+ * answered, or answered and the page still loading what it changed. A
4+ * button that only waits for "submitting" turns back on while the page
5+ * still shows the old figures, so it looks as if nothing happened.
6+ *
7+ * `fields` names the submission, such as `{ intent: "delete", id }`: every
8+ * one must match what was posted, so one row's button does not say
9+ * "Deleting…" while another row's is the one going. With no `fields`, any
10+ * submission that writes counts. A GET form (a search, a filter) is never
11+ * pending here: it only reads.
12+ */
13+export type Submission = {
14+ state: "idle" | "submitting" | "loading";
15+ formMethod?: string | null;
16+ formData?: FormData | null;
17+};
18+
19+export function isPending(submission: Submission, fields?: Record<string, string | null | undefined>): boolean {
20+ if (submission.state === "idle" || !submission.formData) return false;
21+ if (!submission.formMethod || submission.formMethod.toUpperCase() === "GET") return false;
22+ const posted = submission.formData;
23+ return Object.entries(fields ?? {}).every(([name, value]) => value == null || posted.get(name) === value);
24+}
+16−8
3737 import displayFont from "@g1t/theme/fonts/bricolage-grotesque-latin.woff2?url";
3838 import sansFont from "@g1t/theme/fonts/hanken-grotesk-latin.woff2?url";
3939 import { Logo } from "./components/logo";
40−import { Avatar, ButtonLink, notACredential } from "./components/ui";
40+import { Avatar, ButtonLink, SubmitButton, notACredential } from "./components/ui";
4141 import {
4242 DropdownMenu,
4343 DropdownMenuContent,
429429 // Undefined when the root loader itself failed.
430430 const root = useRouteLoaderData<typeof loader>("root");
431431 const user = root?.user;
432− const { pathname } = useLocation();
432+ const { pathname, search } = useLocation();
433+ // /verify lands here with ?sent=1 once it has sent the link again.
434+ const sentAgain = new URLSearchParams(search).get("sent") === "1";
433435 // Drawn around the error page too: a 404 keeps the sidebar out of a
434436 // project or workspace the viewer cannot see.
435437 const error = useRouteError();
449451 action="/verify"
450452 className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b border-warn/30 bg-warn/10 px-4 py-2 text-sm"
451453 >
452− <span>
453− Confirm your email address to create repositories and push. We
454− sent you a link.
454+ <span role="status">
455+ {sentAgain
456+ ? "We sent the link again. It can take a minute to arrive."
457+ : "Confirm your email address to create repositories and push. We sent you a link."}
455458 </span>
456− <button type="submit" className="font-medium underline underline-offset-4">
457− Send it again
458− </button>
459+ <SubmitButton
460+ name="intent"
461+ value="resend-verification"
462+ pending="Sending…"
463+ className="inline-flex items-center gap-1.5 font-medium underline underline-offset-4 disabled:opacity-50"
464+ >
465+ {sentAgain ? "Send another" : "Send it again"}
466+ </SubmitButton>
459467 </Form>
460468 );
461469 const banner =
+4−5
1−import { Form, Link, redirect, useNavigation } from "react-router";
1+import { Form, Link, redirect } from "react-router";
22
33 import type { Route } from "./+types/auth-github-username";
44 import { AuthCard } from "../components/auth-card";
55 import { GithubMark } from "../components/github";
6−import { Button, ErrorText, Field, Input } from "../components/ui";
6+import { ErrorText, Field, Input, SubmitButton } from "../components/ui";
77 import { PENDING_COOKIE, cookie, readCookie } from "../lib/github";
88 import { githubSignIn } from "../lib/github.server";
99 import { page } from "../lib/meta";
4444 }
4545
4646 export default function GithubUsername({ loaderData, actionData }: Route.ComponentProps) {
47− const busy = useNavigation().state === "submitting";
4847 return (
4948 <AuthCard
5049 title="Create your account"
8483 )}
8584 <ErrorText>{actionData?.error}</ErrorText>
8685 <div className="pt-2 *:w-full">
87− <Button type="submit" disabled={busy}>
86+ <SubmitButton pending="Creating account…">
8887 Create account
89− </Button>
88+ </SubmitButton>
9089 </div>
9190 </Form>
9291 </AuthCard>
+7−5
33
44 import type { Route } from "./+types/device";
55 import { page } from "../lib/meta";
6−import { Button, ErrorText, Field, Input } from "../components/ui";
6+import { Button, ErrorText, Field, Input, SubmitButton, usePending } from "../components/ui";
77 import { identity } from "../lib/services.server";
88 import { assertSameOrigin, requireUser } from "../lib/session.server";
99
3939
4040 export default function Device({ loaderData, actionData }: Route.ComponentProps) {
4141 const { user, code, pending } = loaderData;
42+ // Either answer turns both buttons off until it is in.
43+ const deciding = usePending();
4244
4345 if (actionData && "done" in actionData) {
4446 const approved = actionData.done === "approved";
8688 <Form method="post" className="mt-6 flex gap-3">
8789 <input type="hidden" name="code" value={pending.userCode} />
8890 <div className="grow *:w-full">
89− <Button variant="accent" type="submit" name="decision" value="approve">
91+ <SubmitButton variant="accent" name="decision" value="approve" pending="Approving…" disabled={deciding}>
9092 Approve
91− </Button>
93+ </SubmitButton>
9294 </div>
93− <Button variant="quiet" type="submit" name="decision" value="deny">
95+ <SubmitButton variant="quiet" name="decision" value="deny" pending="Denying…" disabled={deciding}>
9496 Deny
95− </Button>
97+ </SubmitButton>
9698 </Form>
9799 </>
98100 ) : (
+2−2
33 import type { Route } from "./+types/forgot";
44 import { page } from "../lib/meta";
55 import { AuthCard } from "../components/auth-card";
6−import { Button, Field, Input } from "../components/ui";
6+import { Field, Input, SubmitButton } from "../components/ui";
77 import { identity } from "../lib/services.server";
88 import { assertSameOrigin, clientOf } from "../lib/session.server";
99
4040 <Input name="email" type="email" autoComplete="email" required autoFocus />
4141 </Field>
4242 <div className="pt-2 *:w-full">
43− <Button type="submit">Send reset link</Button>
43+ <SubmitButton pending="Sending…">Send reset link</SubmitButton>
4444 </div>
4545 </Form>
4646 )}
+42−29
11 import { CircleAlert, Lock, Ticket } from "lucide-react";
2−import { Form, Link, data, redirect, useNavigation } from "react-router";
2+import { Form, Link, data, redirect } from "react-router";
33
44 import type { InvitePreview, User } from "@g1t/contracts";
55
88 import { Mark } from "../components/logo";
99 import { ContinueWithGithub, OrDivider } from "../components/github";
1010 import { Honeypot } from "../components/honeypot";
11−import { Avatar, Button, ButtonLink, ErrorText, Field, Input } from "../components/ui";
11+import { Avatar, ButtonLink, ErrorText, Field, Input, SubmitButton } from "../components/ui";
1212 import { githubSignInEnabled } from "../lib/github.server";
1313 import { identity } from "../lib/services.server";
1414 import { cleanCode, landingFor, looksAutomated, suggestUsername, welcomeCookie } from "../lib/invites";
115115
116116 const user = requireUser(context, request);
117117 const result = await identity.acceptInvite(user, code);
118− if (!result.ok) return data({ error: result.error.message }, { status: 422 });
118+ // Said with a 200, so the page loads again and shows the invite as it
119+ // now stands (used up, revoked) beside the reason: after a 4xx answer
120+ // React Router keeps the page's data as it was.
121+ if (!result.ok) return { error: result.error.message };
119122 throw landIn(request, invite, [], true);
120123 }
121124
194197 /** Signing up, on this page: the address the invite was sent to, a username, a password. */
195198 function SignUp({ loaded, error }: { loaded: Loaded; error: string | null }) {
196199 const invite = loaded.invite!;
197− const busy = useNavigation().state === "submitting";
198200 const here = `/invite/${loaded.code}`;
199201 const back = `${here}?accept=1`;
200202 const github = `/auth/github?${new URLSearchParams({ invite: loaded.code, next: back })}`;
247249 </Field>
248250 <ErrorText>{error}</ErrorText>
249251 <div className="pt-1 *:w-full">
250− <Button type="submit" disabled={busy}>
252+ <SubmitButton pending="Creating account…" match={{ intent: "register" }}>
251253 {invite.workspace
252254 ? `Create account and join ${invite.workspace.name}`
253255 : invite.repository
254256 ? "Create account and accept"
255257 : "Create account"}
256− </Button>
258+ </SubmitButton>
257259 </div>
258260 </Form>
259261 <p className="mt-5 text-center text-sm text-muted">
281283 Signed in as <span className="font-mono text-fg">{viewer.username}</span>
282284 </p>
283285 );
286+ // Its own intent, which /logout ignores, so only its button says it is working.
284287 const signOut = (label: string, variant: "primary" | "quiet") => (
285288 <Form method="post" action={`/logout?next=${encodeURIComponent(here)}`}>
286− <Button type="submit" variant={variant}>
289+ <input type="hidden" name="intent" value="sign-out" />
290+ <SubmitButton variant={variant} pending="Signing out…" match={{ intent: "sign-out" }}>
287291 {label}
288− </Button>
292+ </SubmitButton>
289293 </Form>
290294 );
291295 if (invite.forViewer === false) {
328332 );
329333 }
330334 return (
331− <Form method="post" className="space-y-4">
332− <input type="hidden" name="intent" value="accept" />
333− {signedInAs}
334− {invite.forViewer === null && (
335− <p className="text-sm text-muted">This invite is for anyone with the link. Accepting uses it up.</p>
336− )}
337− <ErrorText>{error}</ErrorText>
338− <div className="flex flex-wrap items-center gap-3">
339− <Button type="submit">{joinLabel(invite)}</Button>
340− <span className="text-sm text-muted">
341− Not you?{" "}
342− <button
343− type="submit"
344− formAction={`/logout?next=${encodeURIComponent(here)}`}
345− className="text-fg underline underline-offset-4"
346− >
347− Sign out
348− </button>
349− </span>
350− </div>
351− </Form>
335+ <>
336+ {/* "Not you?" posts this form, so it does not carry the accept form's intent. */}
337+ <Form id="invite-sign-out" method="post" action={`/logout?next=${encodeURIComponent(here)}`} hidden>
338+ <input type="hidden" name="intent" value="sign-out" />
339+ </Form>
340+ <Form method="post" className="space-y-4">
341+ <input type="hidden" name="intent" value="accept" />
342+ {signedInAs}
343+ {invite.forViewer === null && (
344+ <p className="text-sm text-muted">This invite is for anyone with the link. Accepting uses it up.</p>
345+ )}
346+ <ErrorText>{error}</ErrorText>
347+ <div className="flex flex-wrap items-center gap-3">
348+ <SubmitButton pending={invite.workspace ? "Joining…" : "Accepting…"} match={{ intent: "accept" }}>
349+ {joinLabel(invite)}
350+ </SubmitButton>
351+ <span className="text-sm text-muted">
352+ Not you?{" "}
353+ <SubmitButton
354+ form="invite-sign-out"
355+ pending="Signing out…"
356+ match={{ intent: "sign-out" }}
357+ className="inline-flex items-center gap-1 text-fg underline underline-offset-4 disabled:opacity-50"
358+ >
359+ Sign out
360+ </SubmitButton>
361+ </span>
362+ </div>
363+ </Form>
364+ </>
352365 );
353366 }
354367
+2−2
77 import { ContinueWithGithub, GithubMark, OrDivider } from "../components/github";
88 import { PENDING_COOKIE, cookie, readCookie } from "../lib/github";
99 import { githubSignIn, githubSignInEnabled } from "../lib/github.server";
10−import { Button, ErrorText, Field, Input } from "../components/ui";
10+import { ErrorText, Field, Input, SubmitButton } from "../components/ui";
1111 import { identity } from "../lib/services.server";
1212 import {
1313 assertSameOrigin,
101101 </Field>
102102 <ErrorText>{actionData?.error}</ErrorText>
103103 <div className="pt-2 *:w-full">
104− <Button type="submit">Sign in</Button>
104+ <SubmitButton pending="Signing in…">Sign in</SubmitButton>
105105 </div>
106106 <p className="text-center text-sm">
107107 <Link to="/forgot" className="text-muted hover:text-fg">
+15−9
11 import { ArrowLeft, Lock, Plus } from "lucide-react";
2−import { Form, Link, redirect, useNavigation } from "react-router";
2+import { Form, Link, redirect } from "react-router";
33
44 import type { Route } from "./+types/new-github";
55 import { GithubMark } from "../components/github";
6−import { Button, ButtonLink, ErrorText, Pill } from "../components/ui";
6+import { ButtonLink, ErrorText, Pill, SubmitButton } from "../components/ui";
77 import { CheckboxOption } from "../components/ui/checkbox";
88 import { FieldLegend, FieldSet } from "../components/ui/field";
99 import { RadioCard, RadioGroup } from "../components/ui/radio-group";
6161 const installationId = Number(form.get("installation"));
6262 if (form.get("intent") === "remove-installation") {
6363 const removed = await githubApp.removeInstallation(user, workspace, installationId);
64− if (!removed.ok) return { error: removed.error.message, results: [] };
64+ // Shown by the list it was removed from, which is there with or without an account chosen.
65+ if (!removed.ok) return { error: null, removeError: removed.error.message, results: [] };
6566 throw redirect(`/new/github?workspace=${encodeURIComponent(workspace)}`);
6667 }
6768 const mode = (["import", "mirror", "push"] as const).find((option) => option === form.get("mode")) ?? "import";
8485
8586 export default function NewFromGithub({ loaderData, actionData }: Route.ComponentProps) {
8687 const { workspace, workspaces, installations, installation, repositories, linked, owner } = loaderData;
87− const busy = useNavigation().state === "submitting";
8888 const here = `/new/github?workspace=${workspace}`;
8989 const names = new Map((repositories?.repositories ?? []).map((repo) => [repo.id, repo.fullName]));
9090 return (
191191 <input type="hidden" name="intent" value="remove-installation" />
192192 <input type="hidden" name="workspace" value={workspace} />
193193 <input type="hidden" name="installation" value={item.id} />
194− <button type="submit" className="text-xs text-faint hover:text-danger">
194+ <SubmitButton
195+ pending="Removing…"
196+ match={{ intent: "remove-installation", installation: String(item.id) }}
197+ className="inline-flex items-center gap-1 text-xs text-faint hover:text-danger disabled:opacity-50"
198+ >
195199 Remove
196− </button>
200+ </SubmitButton>
197201 </Form>
198202 )}
199203 </span>
201205 ))}
202206 </ul>
203207 )}
208+ <ErrorText>{actionData && "removeError" in actionData ? actionData.removeError : null}</ErrorText>
204209 </section>
205210
206211 {installation && (
207212 <Form method="post" className="mt-10 space-y-8">
213+ <input type="hidden" name="intent" value="import" />
208214 <input type="hidden" name="workspace" value={workspace} />
209215 <input type="hidden" name="installation" value={installation.id} />
210216 <FieldSet>
294300 )}
295301 <ErrorText>{actionData?.error}</ErrorText>
296302 <div className="flex items-center gap-3">
297− <Button type="submit" variant="accent" disabled={busy}>
298− {busy ? "Bringing them across…" : "Bring to g1t"}
299− </Button>
303+ <SubmitButton variant="accent" pending="Bringing them across…" match={{ intent: "import" }}>
304+ Bring to g1t
305+ </SubmitButton>
300306 <ButtonLink to={`/new?workspace=${workspace}`} variant="quiet">
301307 Cancel
302308 </ButtonLink>
+4−5
11 import { Box, Download, GitBranch, Globe, Lock, Sparkles } from "lucide-react";
22 import { useState } from "react";
3−import { Form, redirect, useNavigate, useNavigation } from "react-router";
3+import { Form, redirect, useNavigate } from "react-router";
44
55 import type { Route } from "./+types/new";
66 import { page } from "../lib/meta";
7−import { Avatar, Button, ErrorText } from "../components/ui";
7+import { Avatar, ErrorText, SubmitButton } from "../components/ui";
88 import { Field, FieldDescription, FieldLabel, FieldLegend, FieldSet } from "../components/ui/field";
99 import { Input, InputAddon, InputGroup } from "../components/ui/input";
1010 import { RadioCard, RadioGroup } from "../components/ui/radio-group";
9393 const sources = loaderData.github ? SOURCES.map((option) => (option.id === "mirror" ? GITHUB_SOURCE : option)) : SOURCES;
9494 const [workspace, setWorkspace] = useState(loaderData.selected);
9595 const [name, setName] = useState("");
96− const busy = useNavigation().state === "submitting";
9796 return (
9897 <main className="mx-auto max-w-2xl px-4 py-12">
9998 <span className="flex size-10 items-center justify-center rounded-xl bg-accent/10 text-accent ring-1 ring-accent/30">
212211 </FieldSet>
213212
214213 <ErrorText>{actionData?.error}</ErrorText>
215− <Button type="submit" variant="accent" disabled={busy}>
214+ <SubmitButton variant="accent" pending={source === "import" ? "Importing…" : "Creating…"}>
216215 Create project
217− </Button>
216+ </SubmitButton>
218217 </Form>
219218 </main>
220219 );
+7−5
55
66 import type { Route } from "./+types/oauth-authorize";
77 import { page } from "../lib/meta";
8−import { Button, ErrorText } from "../components/ui";
8+import { ErrorText, SubmitButton, usePending } from "../components/ui";
99 import { ScopeChecklist } from "../components/token-scopes";
1010 import { identity } from "../lib/services.server";
1111 import { addresses } from "../lib/addresses.server";
118118
119119 export default function Authorize({ loaderData, actionData }: Route.ComponentProps) {
120120 const { user, request, requested, query } = loaderData;
121+ // Either answer turns both buttons off until it is in.
122+ const deciding = usePending();
121123
122124 if (!request.ok) {
123125 return (
170172 </p>
171173 <ErrorText>{actionData?.error}</ErrorText>
172174 <div className="mt-4 flex gap-2">
173− <Button variant="accent" type="submit" name="decision" value="approve">
175+ <SubmitButton variant="accent" name="decision" value="approve" pending="Approving…" disabled={deciding}>
174176 Approve
175− </Button>
176− <Button variant="quiet" type="submit" name="decision" value="deny">
177+ </SubmitButton>
178+ <SubmitButton variant="quiet" name="decision" value="deny" pending="Denying…" disabled={deciding}>
177179 Deny
178− </Button>
180+ </SubmitButton>
179181 </div>
180182 </div>
181183 </Form>
+7−3
99 import { ContinueWithGithub, OrDivider } from "../components/github";
1010 import { Honeypot } from "../components/honeypot";
1111 import { githubSignInEnabled } from "../lib/github.server";
12−import { Avatar, Button, ErrorText, Field, Input } from "../components/ui";
12+import { Avatar, Button, ErrorText, Field, Input, SubmitButton } from "../components/ui";
1313 import { identity } from "../lib/services.server";
1414 import { cleanCode, looksAutomated } from "../lib/invites";
1515 import { clientKey, registrationMode } from "../lib/registration.server";
187187 </Field>
188188 <ErrorText>{error}</ErrorText>
189189 <div className="pt-2 *:w-full">
190− <Button type="submit">{invite?.workspace ? `Create account and join ${invite.workspace.slug}` : "Create account"}</Button>
190+ <SubmitButton pending="Creating account…">
191+ {invite?.workspace ? `Create account and join ${invite.workspace.slug}` : "Create account"}
192+ </SubmitButton>
191193 </div>
192194 </Form>
193195 </>
262264 </Field>
263265 <ErrorText>{actionData && "requestError" in actionData ? actionData.requestError : undefined}</ErrorText>
264266 <div className="*:w-full">
265− <Button type="submit">Request access</Button>
267+ <SubmitButton pending="Sending…" match={{ intent: "request-access" }}>
268+ Request access
269+ </SubmitButton>
266270 </div>
267271 </Form>
268272 </>
+12−10
11 import { AlertTriangle, ChevronRight, Cloud, Download, GitBranch, GitCommitHorizontal, Info, Package, RotateCw, ServerCog, Square, XCircle } from "lucide-react";
22 import { type ReactNode } from "react";
3−import { Form, Link, useLoaderData, useNavigation, useSearchParams } from "react-router";
3+import { Form, Link, useLoaderData, useSearchParams } from "react-router";
44
55 import type { Annotation, Job, StepState } from "@g1t/contracts";
66
77 import type { Route } from "./+types/actions-run";
88 import { page } from "../../lib/meta";
99 import { LogText, Notes, StatusIcon, duration, shortRef, standingWord, useJobLog } from "../../components/actions";
10−import { Button, ErrorText, TimeAgo } from "../../components/ui";
10+import { ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui";
1111 import { listArtifacts } from "../../lib/artifacts.server";
1212 import { actions } from "../../lib/services.server";
1313 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
184184 const { run, jobs, notes } = detail;
185185 const base = `/${params.owner}/${params.repo}`;
186186 const [search] = useSearchParams();
187− const busy = useNavigation().state === "submitting";
187+ // One of the run's buttons is working: the others wait for it.
188+ const busy = usePending();
188189 const live = run.status !== "completed";
189190 useRefreshWhile(live, 2500);
190191
211212 {member && !run.error && (
212213 <Form method="post" className="flex gap-2">
213214 {live ? (
214− <Button type="submit" name="intent" value="cancel" variant="quiet" disabled={busy}>
215+ <SubmitButton name="intent" value="cancel" variant="quiet" disabled={busy} pending="Cancelling…">
215216 <Square size={13} />
216217 Cancel run
217− </Button>
218+ </SubmitButton>
218219 ) : (
219220 <>
220221 {anyFailed && (
221− <Button type="submit" name="intent" value="rerun-failed" variant="quiet" disabled={busy}>
222+ <SubmitButton name="intent" value="rerun-failed" variant="quiet" disabled={busy} pending="Re-running…">
222223 <RotateCw size={13} />
223224 Re-run failed jobs
224− </Button>
225+ </SubmitButton>
225226 )}
226− <Button type="submit" name="intent" value="rerun" variant="quiet" disabled={busy}>
227+ <SubmitButton name="intent" value="rerun" variant="quiet" disabled={busy} pending="Re-running…">
227228 <RotateCw size={13} />
228229 Re-run all jobs
229− </Button>
230+ </SubmitButton>
230231 </>
231232 )}
232233 </Form>
307308 </Link>
308309 ))}
309310 </nav>
310− {selected && <JobView key={selected.id} job={selected} base={base} />}
311+ {/* Run again, a job keeps its id but its log starts afresh. */}
312+ {selected && <JobView key={`${selected.id}:${run.attempt}`} job={selected} base={base} />}
311313 </div>
312314 )}
313315 </div>
+19−10
11 import { AlertTriangle, FileCode2, GitBranch, Play, PlayCircle } from "lucide-react";
2−import { useState } from "react";
3−import { Form, Link, useNavigation, useSearchParams } from "react-router";
2+import { useEffect, useRef, useState } from "react";
3+import { Form, Link, useSearchParams } from "react-router";
44
55 import type { DispatchInput, Workflow, WorkflowRun } from "@g1t/contracts";
66
88 import { page } from "../../lib/meta";
99 import { Notes, StatusIcon, duration, shortRef } from "../../components/actions";
1010 import { AddCiPrompt } from "../../components/add-ci";
11−import { Button, ComputeNote, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
11+import { Button, ComputeNote, EmptyState, ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui";
1212 import { CheckboxOption } from "../../components/ui/checkbox";
1313 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
1414 import { computeNoteFor } from "../../lib/compute.server";
178178
179179 function RunWorkflow({ workflow }: { workflow: Workflow }) {
180180 const [open, setOpen] = useState(false);
181− const busy = useNavigation().state === "submitting";
181+ // Open, saying it is starting, until the run shows below or the error does.
182+ const running = usePending({ intent: "dispatch", workflow: workflow.id });
183+ const was = useRef(false);
184+ useEffect(() => {
185+ if (was.current && !running) setOpen(false);
186+ was.current = running;
187+ }, [running]);
182188 const inputs = Object.entries(workflow.dispatch ?? {});
183189 const booleans = inputs.filter(([, spec]) => spec.type === "boolean").map(([name]) => name);
184190 return (
190196 {open && (
191197 <Form
192198 method="post"
193− onSubmit={() => setOpen(false)}
194199 className="absolute right-0 z-20 mt-2 w-80 space-y-3 rounded-xl border border-line bg-surface p-4 shadow-xl"
195200 >
201+ <input type="hidden" name="intent" value="dispatch" />
196202 <input type="hidden" name="workflow" value={workflow.id} />
197203 <input type="hidden" name="booleans" value={booleans.join(",")} />
198204 <label className="block">
207213 {inputs.map(([name, spec]) => (
208214 <InputField key={name} name={name} spec={spec} />
209215 ))}
210− <Button type="submit" disabled={busy}>
216+ <SubmitButton match={{ intent: "dispatch", workflow: workflow.id }} pending="Starting…">
211217 <PlayCircle size={14} />
212218 Run
213− </Button>
219+ </SubmitButton>
214220 </Form>
215221 )}
216222 </div>
218224 }
219225
220226 function WorkflowHeader({ workflow, base, member, manage }: { workflow: Workflow; base: string; member: boolean; manage: boolean }) {
221− const busy = useNavigation().state === "submitting";
222227 return (
223228 <div className="space-y-4">
224229 <div className="flex flex-wrap items-start justify-between gap-3">
239244 <input type="hidden" name="intent" value="toggle" />
240245 <input type="hidden" name="workflow" value={workflow.id} />
241246 <input type="hidden" name="enabled" value={workflow.state === "active" ? "false" : "true"} />
242− <Button type="submit" variant="quiet" disabled={busy}>
247+ <SubmitButton
248+ variant="quiet"
249+ match={{ intent: "toggle", workflow: workflow.id }}
250+ pending={workflow.state === "active" ? "Turning off…" : "Turning on…"}
251+ >
243252 {workflow.state === "active" ? "Turn off" : "Turn on"}
244− </Button>
253+ </SubmitButton>
245254 </Form>}
246255 </div>
247256 )}
+37−12
11 import { ExternalLink, Globe, Rocket, RotateCw, Trash2 } from "lucide-react";
22 import type { ReactNode } from "react";
3−import { Form, Link, useNavigation } from "react-router";
3+import { Form, Link } from "react-router";
44
55 import type { Deployment, FeatureState } from "@g1t/contracts";
66
88
99 import type { Route } from "./+types/deployments";
1010 import { page } from "../../lib/meta";
11−import { Button, ButtonLink, ComputeNote, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
11+import { ButtonLink, ComputeNote, EmptyState, ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
1212 import { computeNoteFor } from "../../lib/compute.server";
1313 import { billing, deployments, projects } from "../../lib/services.server";
1414 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
7272
7373 export default function RepoDeployments({ loaderData, actionData, params }: Route.ComponentProps) {
7474 const { settings, deployments: builds, live, plan, can } = loaderData;
75− const busy = useNavigation().state === "submitting";
7675 const base = `/${params.owner}/${params.repo}`;
7776 const production = live.find((app) => app.kind === "production");
7877 const previews = live.filter((app) => app.kind === "preview");
132131 </p>
133132 ) : (
134133 <Form method="post" className="mt-4">
135− <Button variant="accent" type="submit" name="intent" value="enable" disabled={busy || !can.manage_integrations} title={whyNot(can, "manage_integrations")}>
134+ <SubmitButton
135+ variant="accent"
136+ name="intent"
137+ value="enable"
138+ pending="Turning on…"
139+ disabled={!can.manage_integrations}
140+ title={whyNot(can, "manage_integrations")}
141+ >
136142 <Rocket size={14} />
137143 Turn on deployments
138− </Button>
144+ </SubmitButton>
139145 </Form>
140146 )}
141147 </section>
148154 app={production}
149155 off={!settings.production}
150156 actions={
151− can.run ? <AppActions branch={null} up={!!production} busy={busy} /> : null
157+ can.run ? <AppActions branch={null} up={!!production} /> : null
152158 }
153159 />
154160 <div className="rounded-xl border border-line bg-surface p-5">
176182 {host(app.url)}
177183 </a>
178184 <span className="ml-auto shrink-0">
179− {can.run && <AppActions branch={app.branch} up busy={busy} compact />}
185+ {can.run && <AppActions branch={app.branch} up compact />}
180186 </span>
181187 </li>
182188 ))}
283289 );
284290 }
285291
286−function AppActions({ branch, up, busy, compact }: { branch: string | null; up: boolean; busy: boolean; compact?: boolean }) {
292+function AppActions({ branch, up, compact }: { branch: string | null; up: boolean; compact?: boolean }) {
293+ // Which app's buttons say they are working: production has no branch.
294+ const app = branch ?? "production";
287295 return (
288296 <Form method="post" className="flex items-center gap-2">
289297 {branch != null && <input type="hidden" name="branch" value={branch} />}
290− <Button variant="quiet" type="submit" name="intent" value="redeploy" disabled={busy} title="Build again from the current head">
298+ <input type="hidden" name="app" value={app} />
299+ <SubmitButton
300+ variant="quiet"
301+ name="intent"
302+ value="redeploy"
303+ match={{ app }}
304+ icon={compact}
305+ pending="Redeploying…"
306+ title="Build again from the current head"
307+ >
291308 <RotateCw size={13} />
292309 {!compact && "Redeploy"}
293− </Button>
310+ </SubmitButton>
294311 {up && (
295− <Button variant="quiet" type="submit" name="intent" value="take-down" disabled={busy} title="Take it down now">
312+ <SubmitButton
313+ variant="quiet"
314+ name="intent"
315+ value="take-down"
316+ match={{ app }}
317+ icon={compact}
318+ pending="Taking down…"
319+ title="Take it down now"
320+ >
296321 <Trash2 size={13} />
297322 {!compact && "Take down"}
298− </Button>
323+ </SubmitButton>
299324 )}
300325 </Form>
301326 );
+15−12
11 import { Box, CircleAlert, MailOpen } from "lucide-react";
2−import { Form, data, redirect, useNavigation } from "react-router";
2+import { Form, data, redirect } from "react-router";
33
44 import { REPO_ROLE_LABELS, REPO_ROLE_SUMMARIES } from "@g1t/contracts";
55
66 import type { Route } from "./+types/invitations";
7−import { Avatar, Button, ButtonLink, ErrorText } from "../../components/ui";
7+import { Avatar, ButtonLink, ErrorText, SubmitButton, usePending } from "../../components/ui";
88 import { page } from "../../lib/meta";
99 import { identity } from "../../lib/services.server";
1010 import { assertSameOrigin, requireUser } from "../../lib/session.server";
3434 const form = await request.formData();
3535 const accept = form.get("intent") === "accept";
3636 const invitation = await invitationTo(user, params.owner, params.repo);
37− if (!invitation) return data({ error: "This invitation is no longer open." }, { status: 404 });
37+ // Refusals are said with a 200, so the page loads again and shows the
38+ // invitation as it now stands beside the reason: after a 4xx answer
39+ // React Router keeps the page's data as it was.
40+ if (!invitation) return { error: "This invitation is no longer open." };
3841 const answered = await identity.respondRepoInvitation(user, invitation.id, accept);
39− if (!answered.ok) return data({ error: answered.error.message }, { status: 422 });
42+ if (!answered.ok) return { error: answered.error.message };
4043 // The role is theirs from the next request on: straight to the repository.
4144 throw redirect(accept ? `/${invitation.repo}` : "/");
4245 }
4548
4649 export default function RepoInvitation({ loaderData, actionData, params }: Route.ComponentProps) {
4750 const { invitation, viewer } = loaderData;
48− const navigation = useNavigation();
49− const answering = navigation.state !== "idle" ? navigation.formData?.get("intent") : null;
51+ // Either answer: the other button waits for it.
52+ const answering = usePending();
5053 const full = `${params.owner}/${params.repo}`;
5154 if (!invitation) {
5255 return (
117120 </p>
118121 <ErrorText>{actionData?.error}</ErrorText>
119122 <div className="flex flex-col gap-3 sm:flex-row">
120− <Button type="submit" name="intent" value="accept" disabled={answering != null}>
121− {answering === "accept" ? "Accepting…" : "Accept invitation"}
122− </Button>
123− <Button type="submit" name="intent" value="decline" variant="quiet" disabled={answering != null}>
124− {answering === "decline" ? "Declining…" : "Decline"}
125− </Button>
123+ <SubmitButton name="intent" value="accept" disabled={answering} pending="Accepting…">
124+ Accept invitation
125+ </SubmitButton>
126+ <SubmitButton name="intent" value="decline" variant="quiet" disabled={answering} pending="Declining…">
127+ Decline
128+ </SubmitButton>
126129 </div>
127130 </Form>
128131 </div>
+7−6
11 import { env } from "cloudflare:workers";
22 import { Download, Sparkles } from "lucide-react";
3−import { Form, Link, redirect, useNavigation, useSearchParams } from "react-router";
3+import { Form, Link, redirect, useSearchParams } from "react-router";
44
55 import { PROVIDERS } from "@g1t/contracts";
66
77 import type { Route } from "./+types/issue-new";
88 import { page } from "../../lib/meta";
9−import { Button, ErrorText, Field, Input, Textarea } from "../../components/ui";
9+import { ErrorText, Field, Input, SubmitButton, Textarea } from "../../components/ui";
1010 import { CheckboxOption } from "../../components/ui/checkbox";
1111 import { Label } from "../../components/work";
1212 import { integrations, work } from "../../lib/services.server";
7878 }
7979
8080 export default function NewIssue({ loaderData, actionData }: Route.ComponentProps) {
81− const busy = useNavigation().state === "submitting";
8281 // "Put an agent on …" in the palette arrives with ?agent=1.
8382 const [params] = useSearchParams();
8483 const refused = actionData && "notStarted" in actionData ? actionData.notStarted : null;
101100 <Input name="reference" required placeholder="TECH-1234" aria-label="Ticket key or address" />
102101 </div>
103102 <CheckboxOption name="assign" label="Put an agent on it" className="items-center" labelClassName="text-muted" />
104− <Button type="submit" variant="quiet" disabled={busy}>
103+ <SubmitButton variant="quiet" match={{ intent: "import" }} pending="Importing…">
105104 Import
106− </Button>
105+ </SubmitButton>
107106 </div>
108107 {actionData && "importError" in actionData && (
109108 <div className="mt-2">
180179 </div>
181180 )}
182181 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
183− <Button type="submit">Open issue</Button>
182+ <SubmitButton name="intent" value="open" pending="Opening…">
183+ Open issue
184+ </SubmitButton>
184185 </Form>
185186 </div>
186187 );
+24−24
11 import { env } from "cloudflare:workers";
22 import { Bot, ExternalLink, GitCommitHorizontal, GitMerge, Play, Sparkles } from "lucide-react";
3−import { Form, Link, redirect, useNavigation } from "react-router";
3+import { Form, Link, redirect } from "react-router";
44
55 import { type Pull, PROVIDERS, workOwner } from "@g1t/contracts";
66
1010 import { AgentStepLine } from "../../components/agents";
1111 import {
1212 Avatar,
13− Button,
1413 CopyLine,
1514 EmptyState,
1615 ComputeNote,
1716 ErrorText,
1817 Input,
18+ SubmitButton,
1919 Textarea,
2020 TimeAgo,
2121 } from "../../components/ui";
235235 const { issue, pulls, comments, viewer, labels, agentsEnabled, members, canManage, can } = loaderData;
236236
237237 // Follow agents at work without a manual reload.
238− const navigation = useNavigation();
239238 const running = pulls.some(
240239 (pull) =>
241240 pull.status === "draft" ||
253252 );
254253 useRefreshWhile(Boolean(running || assigned));
255254
256− const starting = navigation.formData?.get("action") === "run-hosted";
255+ // A refusal to start g1t shows by its button; every other error below the discussion.
256+ const runError = actionData && "action" in actionData && actionData.action === "run-hosted";
257257 const base = `/${params.owner}/${params.repo}`;
258258 const open = issue.state === "open";
259259 const reference = `${params.owner}/${params.repo}#${issue.number}`;
352352 <Form method="post" className="flex flex-wrap justify-end gap-2">
353353 {open ? (
354354 <>
355− <Button variant="quiet" type="submit" name="action" value="close-not-planned">
355+ <SubmitButton variant="quiet" name="action" value="close-not-planned" pending="Closing…">
356356 Close as not planned
357− </Button>
358− <Button variant="quiet" type="submit" name="action" value="close-completed">
357+ </SubmitButton>
358+ <SubmitButton variant="quiet" name="action" value="close-completed" pending="Closing…">
359359 Close issue
360− </Button>
360+ </SubmitButton>
361361 </>
362362 ) : (
363− <Button variant="quiet" type="submit" name="action" value="reopen">
363+ <SubmitButton variant="quiet" name="action" value="reopen" pending="Reopening…">
364364 Reopen issue
365− </Button>
365+ </SubmitButton>
366366 )}
367367 </Form>
368368 )}
369369 </div>
370370 </div>
371371 <div className="mt-2">
372− {!(actionData && "action" in actionData) && <ErrorText>{actionData?.error}</ErrorText>}
372+ {!runError && <ErrorText>{actionData?.error}</ErrorText>}
373373 </div>
374374 </div>
375375
489489 <Form method="post" className="mt-3 space-y-2">
490490 <input type="hidden" name="action" value="run-hosted" />
491491 <div className="*:w-full">
492− <Button variant="accent" type="submit" disabled={starting}>
492+ <SubmitButton variant="accent" match={{ action: "run-hosted" }} pending="Starting a sandbox…">
493493 <Sparkles size={14} />
494− {starting ? "Starting a sandbox…" : "Assign to g1t"}
495− </Button>
494+ Assign to g1t
495+ </SubmitButton>
496496 </div>
497497 <details>
498498 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
511511 agent and fixes. You get it back ready to merge.
512512 </p>
513513 <ComputeNote note={loaderData.computeNote} />
514− {actionData && "action" in actionData && actionData.action === "run-hosted" && (
515− <ErrorText>{actionData.error}</ErrorText>
516− )}
514+ {runError && <ErrorText>{actionData.error}</ErrorText>}
517515 </Form>
518516 )}
519517
547545 ))}
548546 <input type="hidden" name="assignee" value={viewer.username} />
549547 <div className="*:w-full">
550− <Button variant="quiet" type="submit">
548+ <SubmitButton variant="quiet" name="who" value="self" pending="Assigning…">
551549 Assign yourself
552− </Button>
550+ </SubmitButton>
553551 </div>
554552 </Form>
555553 )}
560558 <Form method="post" className="mt-2 space-y-2" key={issue.assignees.join()}>
561559 <input type="hidden" name="action" value="assign" />
562560 <PeoplePicker name="assignee" members={members} chosen={issue.assignees} />
563− <Button variant="quiet" type="submit">
561+ <SubmitButton variant="quiet" name="who" value="picked" pending="Saving…">
564562 Save assignees
565− </Button>
563+ </SubmitButton>
566564 </Form>
567565 </details>
568566 </div>
587585 </p>
588586 <Input name="agent" placeholder="Who is working, e.g. claude-code" maxLength={60} />
589587 <div className="*:w-full">
590− <Button type="submit">Open pull request</Button>
588+ <SubmitButton match={{ action: "open-pull" }} pending="Opening…">
589+ Open pull request
590+ </SubmitButton>
591591 </div>
592592 <p className="text-xs text-muted">
593593 Already pushed a branch?{" "}
631631 ))}
632632 </div>
633633 <Input name="labels" placeholder="New labels, comma separated" />
634− <Button variant="quiet" type="submit">
634+ <SubmitButton variant="quiet" match={{ action: "labels" }} pending="Saving…">
635635 Save labels
636− </Button>
636+ </SubmitButton>
637637 </Form>
638638 </details>
639639 )}
+6−6
11 import { env } from "cloudflare:workers";
22 import { GitPullRequest, MessageSquare, Plus, Sparkles, X } from "lucide-react";
3−import { Form, Link, useNavigation, useRouteLoaderData } from "react-router";
3+import { Form, Link, useRouteLoaderData } from "react-router";
44
55 import type { Route } from "./+types/issues";
66 import { page } from "../../lib/meta";
7−import { Button, ButtonLink, ComputeNote, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
7+import { ButtonLink, ComputeNote, EmptyState, ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
88 import { Checkbox } from "../../components/ui/checkbox";
99 import {
1010 Assignee,
9191 const base = `${repo}/issues`;
9292 const stateQuery = state === "closed" ? "state=closed" : "";
9393 const assignable = agentsEnabled && state === "open" && issues.length > 0;
94− const assigning = useNavigation().state === "submitting";
94+
9595 // An archived repository's issues are locked: no new ones.
9696 const layout = useRouteLoaderData("routes/repo/layout") as { repo?: { archivedAt?: string | null } } | undefined;
9797 const archived = Boolean(layout?.repo?.archivedAt);
138138 Tick the issues to hand over. g1t takes each one in a run of its own,
139139 and they all work at once.
140140 </p>
141− <Button variant="accent" type="submit" disabled={assigning}>
142− {assigning ? "Starting sandboxes…" : "Assign to g1t"}
143− </Button>
141+ <SubmitButton variant="accent" pending="Starting sandboxes…">
142+ Assign to g1t
143+ </SubmitButton>
144144 {loaderData.computeNote && (
145145 <div className="basis-full">
146146 <ComputeNote note={loaderData.computeNote} />
+10−5
2020 RotateCw,
2121 } from "lucide-react";
2222 import { type ReactNode, Suspense } from "react";
23−import { Await, Form, Link, useNavigation } from "react-router";
23+import { Await, Form, Link } from "react-router";
2424
2525 import {
2626 type AgentRun,
4646 import { ProductionShot } from "../../components/production-shot";
4747 import { GithubLinkStrip } from "../../components/github";
4848 import { githubApp } from "../../lib/github.server";
49−import { Avatar, Button, ButtonLink, CopyLine, TimeAgo } from "../../components/ui";
49+import { Avatar, ButtonLink, CopyLine, SubmitButton, TimeAgo } from "../../components/ui";
5050 import { ChangeSize } from "../../components/work";
5151 import {
5252 type ActivityItem,
664664 const productionUrl = production ? (settings?.primaryDomain ? `https://${settings.primaryDomain}` : production.url) : null;
665665 const previews = live.filter((app) => app.kind === "preview");
666666 const latestProduction = builds.find((build) => build.kind === "production") ?? null;
667− const busy = useNavigation().state === "submitting";
668667 const addresses = useAddresses();
669668 const source = project?.source.kind === "hosted" ? project.source : null;
670669 const moving = agentsLive.length > 0 || columns.working.length + columns.checking.length > 0;
748747 )}
749748 {settings?.enabled ? (
750749 loaderData.can.run && <Form method="post">
751− <Button type="submit" variant="quiet" disabled={busy} title="Build production again from the default branch">
750+ <SubmitButton
751+ variant="quiet"
752+ name="intent"
753+ value="redeploy"
754+ pending="Redeploying…"
755+ title="Build production again from the default branch"
756+ >
752757 <RotateCw size={14} />
753758 Redeploy
754− </Button>
759+ </SubmitButton>
755760 </Form>
756761 ) : loaderData.can.manage_integrations && (
757762 <ButtonLink to={`${base}/settings/deployments`} variant="accent">
+9−8
11 import { env } from "cloudflare:workers";
22 import type { G1tEvent } from "@g1t/contracts";
33 import { ArrowRight, CircleCheck, FileCode2, Sparkles } from "lucide-react";
4−import { Form, Link, data, useNavigation } from "react-router";
4+import { Form, Link, data } from "react-router";
55
66 import type { Route } from "./+types/plan";
77 import { refusal, requireRepo } from "../../lib/access.server";
1010 import { Markdown } from "../../components/markdown";
1111 import { Activity, Exchanges } from "../../components/activity";
1212 import { Outcome } from "../../components/outcome";
13−import { Button, ErrorText, TimeAgo } from "../../components/ui";
13+import { ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui";
1414 import { Checkbox } from "../../components/ui/checkbox";
1515 import { Label } from "../../components/work";
1616 import { billing, events, identity, work } from "../../lib/services.server";
9191 export default function PlanPage({ loaderData, actionData, params }: Route.ComponentProps) {
9292 const { plan, costMicros, activity } = loaderData;
9393 const base = `/${params.owner}/${params.repo}`;
94− const applying = useNavigation().state === "submitting";
94+ // Either way of applying it, so the other button waits too.
95+ const applying = usePending();
9596
9697 // The agent is still writing it.
9798 const planning = plan.status === "planning";
262263
263264 {plan.status === "ready" && (
264265 <div className="mt-6 flex flex-wrap items-center gap-3">
265− <Button variant="accent" type="submit" name="action" value="assign" disabled={applying || !loaderData.can.run} title={whyNot(loaderData.can, "run")}>
266+ <SubmitButton variant="accent" name="action" value="assign" pending="Opening issues…" disabled={applying || !loaderData.can.run} title={whyNot(loaderData.can, "run")}>
266267 <Sparkles size={15} />
267− {applying ? "Opening issues…" : "Open these and assign g1t"}
268− </Button>
269− <Button variant="quiet" type="submit" name="action" value="open" disabled={applying || !loaderData.can.run} title={whyNot(loaderData.can, "run")}>
268+ Open these and assign g1t
269+ </SubmitButton>
270+ <SubmitButton variant="quiet" name="action" value="open" pending="Opening issues…" disabled={applying || !loaderData.can.run} title={whyNot(loaderData.can, "run")}>
270271 Only open the issues
271− </Button>
272+ </SubmitButton>
272273 <span className="text-xs text-muted">
273274 Untick any you do not want. Agents work on the independent ones at once
274275 and the rest follow as what they depend on merges.
+6−6
11 import { env } from "cloudflare:workers";
22 import { Sparkles } from "lucide-react";
3−import { Form, Link, redirect, useNavigation } from "react-router";
3+import { Form, Link, redirect } from "react-router";
44
55 import type { Plan } from "@g1t/contracts";
66
88 import { refusal, requireRepo } from "../../lib/access.server";
99 import { whyNot } from "../../lib/access";
1010 import { page } from "../../lib/meta";
11−import { Button, ComputeNote, EmptyState, ErrorText, Textarea, TimeAgo } from "../../components/ui";
11+import { ComputeNote, EmptyState, ErrorText, SubmitButton, Textarea, TimeAgo } from "../../components/ui";
1212 import { computeNoteFor } from "../../lib/compute.server";
1313 import { work } from "../../lib/services.server";
1414 import {
5858 export default function Plans({ loaderData, actionData, params }: Route.ComponentProps) {
5959 const { plans } = loaderData;
6060 const base = `/${params.owner}/${params.repo}`;
61− const starting = useNavigation().state === "submitting";
61+
6262 return (
6363 <div className="grid gap-10 lg:grid-cols-[1fr_20rem]">
6464 <div className="min-w-0">
7979 }
8080 />
8181 <div className="flex flex-wrap items-center gap-3">
82− <Button variant="accent" type="submit" disabled={starting || !loaderData.can.run} title={whyNot(loaderData.can, "run")}>
82+ <SubmitButton variant="accent" pending="Starting the planner…" disabled={!loaderData.can.run} title={whyNot(loaderData.can, "run")}>
8383 <Sparkles size={15} />
84− {starting ? "Starting the planner…" : "Plan it"}
85− </Button>
84+ Plan it
85+ </SubmitButton>
8686 <span className="text-xs text-muted">
8787 Nothing is opened until you have read the plan.
8888 </span>
+2−2
66 import { cloneUrl, useAddresses } from "../../lib/addresses";
77 import { Combobox } from "../../components/ui/combobox";
88 import {
9− Button,
9+ SubmitButton,
1010 CopyLine,
1111 EmptyState,
1212 ErrorText,
120120 <Input name="issue" type="number" min={1} defaultValue={issue} placeholder="12" />
121121 </Field>
122122 <ErrorText>{actionData?.error}</ErrorText>
123− <Button type="submit">Open pull request</Button>
123+ <SubmitButton pending="Opening…">Open pull request</SubmitButton>
124124 </Form>
125125 );
126126 }
+41−32
2424 Wrench,
2525 } from "lucide-react";
2626 import { Suspense } from "react";
27−import { Await, Form, Link, redirect, useNavigation } from "react-router";
27+import { Await, Form, Link, redirect } from "react-router";
2828
2929 import {
3030 type Capability,
4848 import { Markdown } from "../../components/markdown";
4949 import {
5050 Avatar,
51− Button,
5251 ButtonLink,
5352 CopyLine,
5453 EmptyState,
5554 ComputeNote,
5655 ErrorText,
56+ SubmitButton,
5757 Textarea,
5858 TimeAgo,
59+ usePending,
5960 } from "../../components/ui";
6061 import { CheckboxOption } from "../../components/ui/checkbox";
6162 import { WorkflowStatuses } from "../../components/actions";
584585 ];
585586 // A catch-up: the click shows at once; the answer says whether it is
586587 // done already or a sandbox is on it.
587− const navigation = useNavigation();
588− const catchUpPending = navigation.state !== "idle" && navigation.formData?.get("action") === "update";
588+ const catchUpPending = usePending({ action: "update" });
589589 const catchUp = actionData?.action === "update" ? actionData : null;
590590 const caughtUp = catchUp && "updated" in catchUp ? catchUp.updated : null;
591591 const agentCatchUp = catchUp && "agent" in catchUp ? catchUp.agent : null;
770770 {canRun &&
771771 pull.runtime === "hosted" &&
772772 (working || ["working", "revising", "catching_up", "answering"].includes(lifecycle?.stage ?? "")) && (
773− <Form method="post" className="mt-4 rounded-2xl bg-surface p-4 ring-1 ring-merged/30">
773+ // Keyed by the messages so the box empties once one shows below.
774+ <Form method="post" className="mt-4 rounded-2xl bg-surface p-4 ring-1 ring-merged/30" key={messages.length}>
774775 <p className="flex items-center gap-2 text-sm font-medium">
775776 <Sparkles size={15} className="text-merged" />
776777 Message the agent
789790 placeholder="Keep the old flag working too…"
790791 className="h-9 min-w-0 grow rounded-md bg-bg px-3 text-sm ring-1 ring-line outline-none placeholder:text-faint focus:ring-merged/60"
791792 />
792− <Button type="submit">Send</Button>
793+ <SubmitButton match={{ action: "message" }} pending="Sending…">
794+ Send
795+ </SubmitButton>
793796 </div>
794797 </Form>
795798 )}
912915 Edit description
913916 </summary>
914917 <Form method="post" className="mt-3 space-y-2">
918+ <input type="hidden" name="action" value="describe" />
915919 <Textarea
916920 name="summary"
917921 rows={6}
918922 placeholder="What changed and why"
919923 defaultValue={pull.body ?? ""}
920924 />
921− <Button type="submit">Save</Button>
925+ <SubmitButton match={{ action: "describe" }} pending="Saving…">
926+ Save
927+ </SubmitButton>
922928 </Form>
923929 </details>
924930 )}
936942 </StatusRow>
937943 {canManage && (
938944 <Form method="post" className="space-y-2 px-4 py-3">
945+ <input type="hidden" name="action" value="ready" />
939946 <Textarea name="summary" rows={3} placeholder="What changed and why" />
940− <Button type="submit">Mark ready for review</Button>
947+ <SubmitButton match={{ action: "ready" }} pending="Marking ready…">
948+ Mark ready for review
949+ </SubmitButton>
941950 </Form>
942951 )}
943952 </StatusBox>
10221031 {canUpdate && (
10231032 <Form method="post" className="mt-2">
10241033 <input type="hidden" name="action" value="update" />
1025− <Button variant="quiet" type="submit" disabled={catchUpPending}>
1026− {catchUpPending && <Loader size={14} className="animate-spin" />}
1027− {catchUpPending ? `Merging ${defaultBranch} in…` : `Catch up with ${defaultBranch} now`}
1028− </Button>
1034+ <SubmitButton variant="quiet" match={{ action: "update" }} pending={`Merging ${defaultBranch} in…`}>
1035+ Catch up with {defaultBranch} now
1036+ </SubmitButton>
10291037 </Form>
10301038 )}
10311039 {catchUp && "error" in catchUp && <ErrorText>{catchUp.error}</ErrorText>}
10621070 <Layers size={15} />
10631071 See the queue
10641072 </ButtonLink>
1065− <Button variant="quiet" type="submit" name="action" value="unqueue">
1073+ <SubmitButton variant="quiet" name="action" value="unqueue" pending="Removing…">
10661074 Remove from the queue
1067− </Button>
1075+ </SubmitButton>
10681076 </Form>
10691077 )}
10701078 {canMerge && !landing && lifecycle?.stage !== "queued" && (
10841092 />
10851093 )}
10861094 <div className="flex flex-wrap items-center gap-3">
1087− <Button
1095+ <SubmitButton
10881096 variant="accent"
1089− type="submit"
10901097 name="action"
10911098 value="merge"
10921099 disabled={mergeBlocked != null}
10931100 title={mergeBlocked ?? undefined}
1101+ pending={mergeQueue ? "Adding to the queue…" : "Merging…"}
10941102 >
10951103 {mergeQueue ? <Layers size={15} /> : <GitMerge size={15} />}
10961104 {mergeQueue ? "Add to the merge queue" : `Merge into ${defaultBranch}`}
1097− </Button>
1105+ </SubmitButton>
10981106 <span className={`text-xs ${mergeBlocked ? "text-danger" : "text-muted"}`}>
10991107 {mergeBlocked ?? (mergeQueue
11001108 ? `Tested together with everything ahead of it, then lands on ${defaultBranch}.`
11181126 />
11191127 {canManage && active && (
11201128 <Form method="post" className="flex justify-end">
1121− <Button variant="quiet" type="submit" name="action" value="close">
1129+ <SubmitButton variant="quiet" name="action" value="close" pending="Closing…">
11221130 Close pull request
1123− </Button>
1131+ </SubmitButton>
11241132 </Form>
11251133 )}
11261134 {actionData &&
1127− !["merge", "comment", "rerun-failed", "rerun-workflow", "update", "agent-review", "reviewers", "assign"].includes(
1135+ !["merge", "comment", "stack", "rerun-failed", "rerun-workflow", "update", "agent-review", "reviewers", "assign"].includes(
11281136 String(actionData.action),
11291137 ) && <ErrorText>{actionData.error}</ErrorText>}
11301138 </div>
11611169 <Form method="post" className="mt-3">
11621170 <input type="hidden" name="action" value="stack" />
11631171 <input type="hidden" name="branch" value={preview.branch} />
1164− <button
1165− type="submit"
1166− className="w-full rounded-md border border-line px-3 py-1.5 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg"
1172+ <SubmitButton
1173+ match={{ action: "stack" }}
1174+ pending="Starting the builds…"
1175+ className="flex w-full items-center justify-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg disabled:opacity-50"
11671176 >
11681177 Preview them against this change
1169− </button>
1178+ </SubmitButton>
11701179 </Form>
11711180 )}
11721181 {actionData?.action === "stack" &&
12241233 <input key={name} type="hidden" name="reviewer" value={name} />
12251234 ))}
12261235 <div className="*:w-full">
1227− <Button variant="quiet" type="submit">
1236+ <SubmitButton variant="quiet" match={{ action: "agent-review" }} pending="Asking g1t…">
12281237 <Sparkles size={14} className="text-accent" />
12291238 Request review from g1t
1230− </Button>
1239+ </SubmitButton>
12311240 </div>
12321241 <ComputeNote note={loaderData.computeNote} />
12331242 </Form>
12461255 members={members.filter((name) => name !== workOwner(pull).username)}
12471256 chosen={pull.reviewers.filter((name) => name !== "g1t")}
12481257 />
1249− <Button variant="quiet" type="submit">
1258+ <SubmitButton variant="quiet" match={{ action: "reviewers" }} pending="Saving…">
12501259 Save reviewers
1251− </Button>
1260+ </SubmitButton>
12521261 </Form>
12531262 </details>
12541263 </div>
12811290 ))}
12821291 <input type="hidden" name="assignee" value={viewer.username} />
12831292 <div className="*:w-full">
1284− <Button variant="quiet" type="submit">
1293+ <SubmitButton variant="quiet" name="who" value="self" pending="Assigning…">
12851294 Assign yourself
1286− </Button>
1295+ </SubmitButton>
12871296 </div>
12881297 </Form>
12891298 )}
12941303 <Form method="post" className="mt-2 space-y-2" key={pull.assignees.join()}>
12951304 <input type="hidden" name="action" value="assign" />
12961305 <PeoplePicker name="assignee" members={members} chosen={pull.assignees} />
1297− <Button variant="quiet" type="submit">
1306+ <SubmitButton variant="quiet" name="who" value="picked" pending="Saving…">
12981307 Save assignees
1299− </Button>
1308+ </SubmitButton>
13001309 </Form>
13011310 </details>
13021311 </div>
+11−13
11 import { Mail, UserPlus } from "lucide-react";
22 import { useEffect, useState } from "react";
3−import { Form, Link, useFetcher, useNavigation } from "react-router";
3+import { Form, Link, useFetcher } from "react-router";
44
55 import {
66 BASE_PERMISSION_LABELS,
1616 import { RoleSelect, RolesTable } from "../../components/access";
1717 import { RepoSettingsHeading } from "../../components/repo-settings-heading";
1818 import { SettingsSection as Section } from "../../components/settings-section";
19−import { Avatar, Button, ErrorText, Field, Input } from "../../components/ui";
19+import { Avatar, Button, ErrorText, Field, Input, SubmitButton } from "../../components/ui";
2020 import { Badge } from "../../components/ui/badge";
2121 import {
2222 AlertDialog,
214214 function AddForm({ result }: { result: Outcome | undefined }) {
215215 const [role, setRole] = useState<RepoRole>("write");
216216 const [key, setKey] = useState(0);
217− const navigation = useNavigation();
218− const adding = navigation.state !== "idle" && navigation.formData?.get("intent") === "add";
219217 // Cleared once someone was added, ready for the next.
220218 useEffect(() => {
221219 if (result?.ok) setKey((k) => k + 1);
231229 <span className="mb-1.5 block text-sm font-medium text-muted">Role</span>
232230 <RoleSelect name="role" label="Role" value={role} onValueChange={(value) => setRole(value as RepoRole)} />
233231 </div>
234− <Button type="submit" disabled={adding}>
232+ <SubmitButton match={{ intent: "add" }} pending="Adding…">
235233 <UserPlus size={15} />
236− {adding ? "Adding…" : "Add"}
237− </Button>
234+ Add
235+ </SubmitButton>
238236 </div>
239237 <p className="text-xs text-faint">{REPO_ROLE_LABELS[role]}: {roleLine(role)}</p>
240238 {result &&
356354 {fetcher.data && !fetcher.data.ok && <ErrorText>{fetcher.data.error}</ErrorText>}
357355 <AlertDialogFooter>
358356 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
359− <Button type="submit" variant="danger" disabled={fetcher.state !== "idle"}>
360− {fetcher.state !== "idle" ? "Removing…" : "Remove"}
361− </Button>
357+ <SubmitButton variant="danger" fetcher={fetcher} pending="Removing…">
358+ Remove
359+ </SubmitButton>
362360 </AlertDialogFooter>
363361 </fetcher.Form>
364362 </AlertDialogContent>
396394 <fetcher.Form method="post" className="shrink-0">
397395 <input type="hidden" name="intent" value="revoke" />
398396 <input type="hidden" name="id" value={invitation.id} />
399− <Button type="submit" variant="quiet" disabled={fetcher.state !== "idle"}>
400− {fetcher.state !== "idle" ? "Revoking…" : "Revoke"}
401− </Button>
397+ <SubmitButton variant="quiet" fetcher={fetcher} pending="Revoking…">
398+ Revoke
399+ </SubmitButton>
402400 </fetcher.Form>
403401 </li>
404402 );
+8−8
11 import { env } from "cloudflare:workers";
22 import { AtSign, Tag } from "lucide-react";
3−import { Form, useNavigation } from "react-router";
3+import { Form } from "react-router";
44
55 import { AGENT_HANDLE, mentionsClient } from "@g1t/contracts";
66
77 import type { Route } from "./+types/settings-agents";
88 import { page } from "../../lib/meta";
99 import { RepoSettingsHeading } from "../../components/repo-settings-heading";
10−import { Button, ErrorText, Field, Input, TimeAgo } from "../../components/ui";
10+import { ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui";
1111 import { work } from "../../lib/services.server";
1212 import { instrumented } from "../../lib/perf.server";
1313 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
4747
4848 export default function AgentSettings({ loaderData, actionData, params }: Route.ComponentProps) {
4949 const { rules, labels } = loaderData;
50− const busy = useNavigation().state === "submitting";
5150 const base = `/${params.owner}/${params.repo}`;
5251 return (
5352 <div className="max-w-4xl">
7069 </div>
7170 <Form method="post" className="space-y-3 rounded-xl border border-line bg-surface p-4">
7271 <Field label="Label" hint="An issue that already has it is not affected; adding it again is.">
73− <Input name="label" list="known-labels" defaultValue={rules.label ?? ""} placeholder="agent" maxLength={40} />
72+ {/* Keyed to the saved rule, so turning it off empties the box. */}
73+ <Input key={rules.label ?? ""} name="label" list="known-labels" defaultValue={rules.label ?? ""} placeholder="agent" maxLength={40} />
7474 </Field>
7575 <datalist id="known-labels">
7676 {labels.map((label) => (
7878 ))}
7979 </datalist>
8080 <div className="flex flex-wrap items-center gap-3">
81− <Button type="submit" name="intent" value="save" disabled={busy}>
81+ <SubmitButton name="intent" value="save" pending="Saving…">
8282 Save
83− </Button>
83+ </SubmitButton>
8484 {rules.label && (
85− <Button type="submit" variant="quiet" name="intent" value="off" disabled={busy}>
85+ <SubmitButton variant="quiet" name="intent" value="off" pending="Turning off…">
8686 Turn off
87− </Button>
87+ </SubmitButton>
8888 )}
8989 {rules.updatedBy && rules.updatedAt && (
9090 <span className="text-xs text-faint">
+5−6
11 import { ChevronRight, ShieldCheck } from "lucide-react";
2−import { Form, Link, useNavigation } from "react-router";
2+import { Form, Link } from "react-router";
33
44 import type { Route } from "./+types/settings-branches";
55 import { page } from "../../lib/meta";
77 import { RepoSettingsHeading } from "../../components/repo-settings-heading";
88 import { RequiredChecksPicker } from "../../components/required-checks";
99 import { SettingChoice as Choice, SettingsSection as Section, SettingToggle as Toggle } from "../../components/settings-section";
10−import { Button, ErrorText, TimeAgo } from "../../components/ui";
10+import { ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
1111 import { actions, repos, work } from "../../lib/services.server";
1212 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
1313 import { requireCapability, requireInsider } from "../../lib/access.server";
7272
7373 export default function BranchSettings({ loaderData, actionData }: Route.ComponentProps) {
7474 const { repo, settings, seen, noChecks, canPush } = loaderData;
75− const saving = useNavigation().state === "submitting";
7675 const branch = repo.defaultBranch;
7776 const base = `/${repo.namespace}/${repo.name}`;
7877 const archived = Boolean(repo.archivedAt);
190189 </Section>
191190
192191 <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
193− <Button type="submit" disabled={saving || archived}>
194− {saving ? "Saving…" : "Save settings"}
195− </Button>
192+ <SubmitButton pending="Saving…" disabled={archived}>
193+ Save settings
194+ </SubmitButton>
196195 {actionData?.saved && <span className="text-sm text-muted">Saved.</span>}
197196 <ErrorText>{actionData?.error}</ErrorText>
198197 {settings.updatedBy && settings.updatedAt && !actionData && (
+18−14
11 import { ArrowDownLeft, ArrowUpRight, FileCode2, Network, Trash2 } from "lucide-react";
2−import { Form, Link, useNavigation } from "react-router";
2+import { Form, Link } from "react-router";
33
44 import type { DependencyLink } from "@g1t/contracts";
55
66 import type { Route } from "./+types/settings-dependencies";
77 import { page } from "../../lib/meta";
88 import { RepoSettingsHeading } from "../../components/repo-settings-heading";
9−import { Avatar, Button, EmptyState, ErrorText, Field, Input } from "../../components/ui";
9+import { Avatar, EmptyState, ErrorText, Field, Input, SubmitButton } from "../../components/ui";
1010 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
1111 import { projects } from "../../lib/services.server";
1212 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
4141 return added.ok ? { notice: `Depends on ${on}.` } : { error: added.error.message };
4242 }
4343
44−function Row({ link, base, removable, busy }: { link: DependencyLink; base: string; removable: boolean; busy: boolean }) {
44+function Row({ link, base, removable }: { link: DependencyLink; base: string; removable: boolean }) {
4545 return (
4646 <li className="flex items-center gap-3 px-4 py-3 text-sm">
4747 <Link to={`/${base.split("/")[1]}/${link.slug}`} className="font-medium hover:underline">
6262 <Form method="post" className="ml-auto">
6363 <input type="hidden" name="intent" value="remove" />
6464 <input type="hidden" name="on" value={link.slug} />
65− <button
66− type="submit"
67− disabled={busy}
65+ <SubmitButton
66+ icon
67+ match={{ intent: "remove", on: link.slug }}
6868 aria-label={`Stop depending on ${link.name}`}
69− className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger"
69+ className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger disabled:opacity-50"
7070 >
7171 <Trash2 size={14} />
72− </button>
72+ </SubmitButton>
7373 </Form>
7474 )}
7575 </li>
7878
7979 export default function DependencySettings({ loaderData, actionData, params }: Route.ComponentProps) {
8080 const { dependencies, projects: all } = loaderData;
81− const busy = useNavigation().state === "submitting";
8281 const base = `/${params.owner}/${params.repo}`;
8382 const taken = new Set([params.repo.toLowerCase(), ...dependencies.dependsOn.map((d) => d.slug)]);
8483 const choices = all.filter((project) => !taken.has(project.slug));
116115 ) : (
117116 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
118117 {dependencies.dependsOn.map((link) => (
119− <Row key={link.slug} link={link} base={base} removable busy={busy} />
118+ <Row key={link.slug} link={link} base={base} removable />
120119 ))}
121120 </ul>
122121 )}
123122 </div>
124123 {choices.length > 0 && (
125− <Form method="post" className="mt-4 grid items-end gap-3 rounded-xl border border-line bg-surface p-4 sm:grid-cols-[1fr_1fr_auto]">
124+ // Keyed to what it depends on, so once one is added the form starts over on what is left.
125+ <Form
126+ key={dependencies.dependsOn.map((link) => link.slug).join(" ")}
127+ method="post"
128+ className="mt-4 grid items-end gap-3 rounded-xl border border-line bg-surface p-4 sm:grid-cols-[1fr_1fr_auto]"
129+ >
126130 <input type="hidden" name="intent" value="add" />
127131 <Field label="Project">
128132 <Select name="on" required defaultValue={choices[0].slug}>
146150 <Field label="Its address as">
147151 <Input name="as" placeholder="API_URL" className="font-mono" />
148152 </Field>
149− <Button type="submit" disabled={busy}>
153+ <SubmitButton match={{ intent: "add" }} pending="Adding…">
150154 Add dependency
151− </Button>
155+ </SubmitButton>
152156 </Form>
153157 )}
154158 </section>
164168 ) : (
165169 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
166170 {dependencies.usedBy.map((link) => (
167− <Row key={link.slug} link={link} base={base} removable={false} busy={busy} />
171+ <Row key={link.slug} link={link} base={base} removable={false} />
168172 ))}
169173 </ul>
170174 )}
+8−9
1−import { Form, Link, useNavigation } from "react-router";
1+import { Form, Link } from "react-router";
22
33 import type { DetectedKind } from "@g1t/contracts";
44
55 import type { Route } from "./+types/settings-deployments";
66 import { page } from "../../lib/meta";
77 import { RepoSettingsHeading } from "../../components/repo-settings-heading";
8−import { Button, ErrorText, Field, Input } from "../../components/ui";
8+import { ErrorText, Field, Input, SubmitButton } from "../../components/ui";
99 import { SwitchCard } from "../../components/ui/switch";
1010 import { deployments, projects } from "../../lib/services.server";
1111 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
9696
9797 export default function DeploymentSettings({ loaderData, actionData, params }: Route.ComponentProps) {
9898 const { settings, notDeploying } = loaderData;
99− const busy = useNavigation().state === "submitting";
10099 const base = `/${params.owner}/${params.repo}`;
101100 return (
102101 <div className="max-w-4xl">
128127 </p>
129128 ) : (
130129 <Form method="post" className="mt-4">
131− <Button variant="accent" type="submit" name="intent" value="enable" disabled={busy}>
130+ <SubmitButton variant="accent" name="intent" value="enable" pending="Turning on…">
132131 Turn on deployments
133− </Button>
132+ </SubmitButton>
134133 </Form>
135134 )}
136135 </section>
163162 </Link>{" "}
164163 available to Deployments: each row for Production or Preview, or for all environments.
165164 </p>
166− <Button type="submit" disabled={busy}>
165+ <SubmitButton name="intent" value="save" pending="Saving…">
167166 Save
168− </Button>
167+ </SubmitButton>
169168 </Form>
170169
171170 <section className="mt-10 rounded-xl border border-danger/30 p-5">
175174 running or costing anything. The workspace's plan stays on; turn it off under Billing.
176175 </p>
177176 <Form method="post" className="mt-3">
178− <Button variant="quiet" type="submit" name="intent" value="disable" disabled={busy}>
177+ <SubmitButton variant="quiet" name="intent" value="disable" pending="Turning off…">
179178 Turn off deployments
180− </Button>
179+ </SubmitButton>
181180 </Form>
182181 </section>
183182 </>
+23−18
1−import { CheckCircle2, Globe, Info, RotateCw, Trash2 } from "lucide-react";
1+import { CheckCircle2, Globe, Info, LoaderCircle, RotateCw, Trash2 } from "lucide-react";
22 import { useEffect, useState } from "react";
3−import { Form, Link, useNavigation } from "react-router";
3+import { Form, Link } from "react-router";
44
55 import type { Domain, DomainRecord, DomainStatus } from "@g1t/contracts";
66
77 import type { Route } from "./+types/settings-domains";
88 import { page } from "../../lib/meta";
99 import { RepoSettingsHeading } from "../../components/repo-settings-heading";
10−import { Button, CopyLine, EmptyState, ErrorText, Field, Input } from "../../components/ui";
10+import { Button, CopyLine, EmptyState, ErrorText, Field, Input, SubmitButton, usePending } from "../../components/ui";
1111 import {
1212 AlertDialog,
1313 AlertDialogAction,
9797
9898 export default function DomainSettings({ loaderData, actionData, params }: Route.ComponentProps) {
9999 const { settings, domains, target, available, notice, monthlyMicros, used } = loaderData;
100− const busy = useNavigation().state === "submitting";
101100 const base = `/${params.owner}/${params.repo}`;
102101 const [hostname, setHostname] = useState("");
103102 const twin = twinOf(hostname);
171170 </Field>
172171 </div>
173172 <div className="sm:mb-6">
174− <Button type="submit" variant="accent" disabled={busy || !hostname.trim()}>
173+ <SubmitButton variant="accent" match={{ intent: "add" }} pending="Adding…" disabled={!hostname.trim()}>
175174 Add domain
176− </Button>
175+ </SubmitButton>
177176 </div>
178177 </div>
179178 <CheckboxOption
199198 key={domain.id}
200199 domain={domain}
201200 redirects={domains.filter((d) => d.redirectTo === domain.hostname)}
202− busy={busy}
203201 />
204202 ))
205203 )}
208206 );
209207 }
210208
211−function DomainCard({ domain, redirects, busy }: { domain: Domain; redirects: Domain[]; busy: boolean }) {
209+function DomainCard({ domain, redirects }: { domain: Domain; redirects: Domain[] }) {
212210 return (
213211 <section className="overflow-hidden rounded-xl border border-line bg-surface">
214− <DomainRow domain={domain} busy={busy} />
212+ <DomainRow domain={domain} />
215213 {redirects.map((other) => (
216214 <div key={other.id} className="border-t border-line">
217− <DomainRow domain={other} busy={busy} />
215+ <DomainRow domain={other} />
218216 </div>
219217 ))}
220218 </section>
221219 );
222220 }
223221
224−function DomainRow({ domain, busy }: { domain: Domain; busy: boolean }) {
222+function DomainRow({ domain }: { domain: Domain }) {
225223 const active = domain.status === "active";
226224 return (
227225 <div className="p-5">
240238 <Form method="post">
241239 <input type="hidden" name="intent" value="refresh" />
242240 <input type="hidden" name="id" value={domain.id} />
243− <Button type="submit" variant="quiet" disabled={busy} title="Ask Cloudflare to check the records again now">
241+ <SubmitButton
242+ variant="quiet"
243+ match={{ intent: "refresh", id: domain.id }}
244+ pending="Checking…"
245+ title="Ask Cloudflare to check the records again now"
246+ >
244247 <RotateCw size={14} />
245248 Check now
246− </Button>
249+ </SubmitButton>
247250 </Form>
248251 )}
249− <RemoveDomain domain={domain} busy={busy} />
252+ <RemoveDomain domain={domain} />
250253 </span>
251254 </div>
252255 {domain.error && !active && <p className="mt-2 text-sm text-muted">{domain.error}</p>}
307310 );
308311 }
309312
310−function RemoveDomain({ domain, busy }: { domain: Domain; busy: boolean }) {
313+function RemoveDomain({ domain }: { domain: Domain }) {
314+ // The dialog closes as it posts, so the row's own button says it is going.
315+ const removing = usePending({ intent: "remove", id: domain.id });
311316 return (
312317 <AlertDialog>
313318 <AlertDialogTrigger asChild>
314− <Button type="button" variant="quiet" disabled={busy} aria-label={`Remove ${domain.hostname}`}>
315− <Trash2 size={14} />
316− Remove
319+ <Button type="button" variant="quiet" disabled={removing} aria-label={`Remove ${domain.hostname}`}>
320+ {removing ? <LoaderCircle size={14} className="animate-spin" aria-hidden="true" /> : <Trash2 size={14} />}
321+ {removing ? "Removing…" : "Remove"}
317322 </Button>
318323 </AlertDialogTrigger>
319324 <AlertDialogContent>
+1−3
1−import { Link, useNavigation } from "react-router";
1+import { Link } from "react-router";
22
33 import { RUN_KINDS } from "@g1t/contracts";
44
4040 }
4141
4242 export default function RepoGuardrails({ loaderData, actionData, params }: Route.ComponentProps) {
43− const saving = useNavigation().state === "submitting";
4443 const base = `/${params.owner}/${params.repo}`;
4544 return (
4645 <>
5756 view={loaderData.view}
5857 level="project"
5958 editable
60− saving={saving}
6159 saved={actionData?.saved ?? false}
6260 error={actionData?.error}
6361 />
+8−5
1111 import type { Route } from "./+types/settings-repository";
1212 import { page } from "../../lib/meta";
1313 import { confirmsName, tidyName } from "../../lib/repo-lifecycle";
14−import { Button, ErrorText, Field, Input } from "../../components/ui";
14+import { Button, ErrorText, Field, Input, SubmitButton } from "../../components/ui";
1515 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
1616 import {
1717 AlertDialog,
157157 </Section>
158158
159159 <Section title="Details" about="What the repository is, for its page, search and Explore.">
160− <Form method="post">
160+ {/* Keyed to what is saved, so the fields show the topics as the service tidied them. */}
161+ <Form method="post" key={`${repo.description ?? ""}|${repo.website ?? ""}|${(repo.topics ?? []).join(",")}`}>
161162 <fieldset disabled={archived} className="min-w-0 space-y-3 disabled:cursor-not-allowed disabled:opacity-60">
162163 <input type="hidden" name="intent" value="details" />
163164 <Field label="Description">
173174 <Input name="topics" maxLength={800} defaultValue={(repo.topics ?? []).join(", ")} placeholder="cli, rust" />
174175 </Field>
175176 <div className="flex flex-wrap items-center gap-4 pt-1">
176− <Button type="submit" disabled={posting("details")}>
177− {posting("details") ? "Saving…" : "Save"}
178− </Button>
177+ <SubmitButton match={{ intent: "details" }} pending="Saving…">
178+ Save
179+ </SubmitButton>
179180 <Status intent="details" data={actionData} />
180181 </div>
181182 </fieldset>
187188 about="The branch everything lands on, and renaming branches."
188189 >
189190 <DefaultBranchForm repo={repo} branches={branches} archived={archived || !owner} busy={posting("default-branch")} result={actionData} />
191+ {/* Keyed to the branches, so after a rename it starts over on the branches there are now. */}
190192 <RenameBranchForm
193+ key={branches.join(" ")}
191194 repo={repo}
192195 branches={branches}
193196 owner={owner}
+11−10
11 import { Box, Code2, GitBranch, Rocket } from "lucide-react";
22 import { useState } from "react";
3−import { Form, Link, useNavigation } from "react-router";
3+import { Form, Link } from "react-router";
44
55 import type { DeploysSetting } from "@g1t/contracts";
66
77 import type { Route } from "./+types/settings";
88 import { page } from "../../lib/meta";
99 import { RepoSettingsHeading } from "../../components/repo-settings-heading";
10−import { Button, ErrorText, Field, Input, TimeAgo } from "../../components/ui";
10+import { ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui";
1111 import { RadioGroup, RadioOption } from "../../components/ui/radio-group";
1212 import { DEPLOYS_CHOICES } from "../../lib/project-kind";
1313 import { deployments, identity, projects } from "../../lib/services.server";
5858 const [deploys, setDeploys] = useState<DeploysSetting>(project.deploys);
5959 // Not deploying while Deployments are on is refused: they are turned off first.
6060 const blocked = deploys === "no" && project.deploys !== "no" && deploymentsOn === true;
61− const saving = useNavigation().state === "submitting";
6261 const base = `/${params.owner}/${params.repo}`;
6362 const source = project.source.kind === "hosted" ? project.source : null;
6463 return (
6564 <div className="max-w-4xl">
6665 <RepoSettingsHeading base={base} />
6766 <Form method="post" className="space-y-8">
67+ {/* Enter in a field saves: the form's first submit button is the one
68+ Enter presses, and otherwise it would be the description reset. */}
69+ <button type="submit" name="intent" value="save" tabIndex={-1} aria-hidden="true" className="absolute size-0 overflow-hidden opacity-0" />
6870 <section className="space-y-4">
6971 <h2 className="flex items-center gap-2 text-sm font-medium">
7072 <Box size={15} className="text-accent" />
9294 />
9395 </Field>
9496 {!project.descriptionInherited && (
95− <button
96− type="submit"
97+ <SubmitButton
9798 name="inherit"
9899 value="description"
99− disabled={saving}
100− className="mt-1.5 text-xs text-muted hover:text-fg disabled:opacity-50"
100+ pending="Resetting…"
101+ className="mt-1.5 inline-flex items-center gap-1.5 text-xs text-muted hover:text-fg disabled:opacity-50"
101102 >
102103 Use the repository's description
103− </button>
104+ </SubmitButton>
104105 )}
105106 </div>
106107 </div>
185186 </section>
186187
187188 <div className="flex items-center gap-3">
188− <Button type="submit" disabled={saving || blocked}>
189+ <SubmitButton name="intent" value="save" pending="Saving…" disabled={blocked}>
189190 Save
190− </Button>
191+ </SubmitButton>
191192 {actionData && "saved" in actionData && <span className="text-sm text-accent">Saved.</span>}
192193 <span className="ml-auto text-xs text-faint">
193194 Created{" "}
+2−2
33 import type { Route } from "./+types/reset";
44 import { page } from "../lib/meta";
55 import { AuthCard } from "../components/auth-card";
6−import { Button, ErrorText, Field, Input } from "../components/ui";
6+import { ErrorText, Field, Input, SubmitButton } from "../components/ui";
77 import { identity } from "../lib/services.server";
88 import { assertSameOrigin } from "../lib/session.server";
99
5151 </Field>
5252 <ErrorText>{actionData?.error}</ErrorText>
5353 <div className="pt-2 *:w-full">
54− <Button type="submit">Set password</Button>
54+ <SubmitButton pending="Saving…">Set password</SubmitButton>
5555 </div>
5656 </Form>
5757 </AuthCard>
+3−1
3030 email: primary,
3131 invites_left: invites?.allowance.limit != null ? (invites.allowance.remaining ?? 0) : null,
3232 };
33− return Response.json(data, { headers: { "cache-control": "private, max-age=60" } });
33+ // Not kept by the browser: the menu asks again after a name, address or
34+ // invite changes, and a kept copy would show the old one for a minute.
35+ return Response.json(data, { headers: { "cache-control": "private, no-store" } });
3436 }
+5−3
33
44 import type { Route } from "./+types/applications";
55 import { page } from "../../lib/meta";
6−import { Button, ButtonLink, ErrorText, TimeAgo } from "../../components/ui";
6+import { ButtonLink, ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
77 import { DeleteButton } from "../../components/account-settings";
88 import { AccessSummary, ScopeChecklist } from "../../components/token-scopes";
99 import { grantFromForm } from "../../lib/token-scopes";
7474 Change access
7575 </ButtonLink>
7676 )}
77− <DeleteButton intent="sign-out-application" id={application.id} label="Sign out" />
77+ <DeleteButton intent="sign-out-application" id={application.id} label="Sign out" pending="Signing out…" />
7878 </div>
7979 </div>
8080 {open && (
8888 <ScopeChecklist initial={application.scopes} />
8989 {actionData?.editing === application.id && <ErrorText>{actionData.error}</ErrorText>}
9090 <div className="flex gap-2">
91− <Button type="submit">Save access</Button>
91+ <SubmitButton pending="Saving…" match={{ intent: "update-application", id: application.id }}>
92+ Save access
93+ </SubmitButton>
9294 <ButtonLink variant="quiet" to="." preventScrollReset>
9395 Cancel
9496 </ButtonLink>
+3−1
2222 const form = await request.formData();
2323 // Sensitive changes may first ask for the password (lib/emails.server.ts).
2424 if ((EMAIL_INTENTS as readonly string[]).includes(String(form.get("intent")))) {
25− return { emailAction: await emailAction(user, form, request) };
25+ // Which form posted, so its answer shows beside it.
26+ return { emailAction: await emailAction(user, form, request), emailIntent: String(form.get("intent")) };
2627 }
2728 return null;
2829 }
3233 <EmailsSection
3334 data={loaderData.emails}
3435 actionData={actionData?.emailAction}
36+ intent={actionData?.emailIntent}
3537 hasPassword={loaderData.hasPassword}
3638 />
3739 );
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.