Commit

Merge branch 'worktree-agent-a985c01f43538af01'

syntaqxcommitted Parents0d6fd036b0767cBrowse files
80 files+874−5450/80 viewed
+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 );
+3−3
22
33 import type { Route } from "./+types/github";
44 import { page } from "../../lib/meta";
5−import { Button, ErrorText, TimeAgo } from "../../components/ui";
5+import { ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
66 import { assertSameOrigin, requireUser } from "../../lib/session.server";
77 import { GithubMark } from "../../components/github";
88 import { githubSignIn } from "../../lib/github.server";
5454 )}
5555 <Form method="post">
5656 <input type="hidden" name="intent" value="unlink-github" />
57− <Button variant="quiet" type="submit" disabled={!github.hasPassword} title={github.hasPassword ? undefined : "GitHub is how you sign in. Set a password first."}>
57+ <SubmitButton variant="quiet" pending="Unlinking…" match={{ intent: "unlink-github" }} disabled={!github.hasPassword} title={github.hasPassword ? undefined : "GitHub is how you sign in. Set a password first."}>
5858 Unlink
59− </Button>
59+ </SubmitButton>
6060 </Form>
6161 </div>
6262 </div>
+6−3
33
44 import type { Route } from "./+types/keys";
55 import { page } from "../../lib/meta";
6−import { Button, ErrorText, Field, Input } from "../../components/ui";
6+import { ErrorText, Field, Input, SubmitButton } from "../../components/ui";
77 import { DeleteButton } from "../../components/account-settings";
88 import { assertSameOrigin, requireUser } from "../../lib/session.server";
99
5151 </li>
5252 ))}
5353 </ul>
54− <Form method="post" className={`space-y-3 ${keys.length > 0 ? "mt-4" : ""}`}>
54+ {/* Keyed on the list, so a key once added leaves the fields empty for the next. */}
55+ <Form key={keys.length} method="post" className={`space-y-3 ${keys.length > 0 ? "mt-4" : ""}`}>
5556 <input type="hidden" name="intent" value="add-key" />
5657 <Field label="Title (optional)">
5758 <Input name="title" maxLength={100} />
6061 <Input name="key" placeholder="ssh-ed25519 AAAA…" required />
6162 </Field>
6263 <ErrorText>{actionData?.keyError}</ErrorText>
63− <Button type="submit">Add SSH key</Button>
64+ <SubmitButton pending="Adding…" match={{ intent: "add-key" }}>
65+ Add SSH key
66+ </SubmitButton>
6467 </Form>
6568 </section>
6669 );
+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.