Skip to content

Commit

web: workspace billing, people, tokens, integrations and settings buttons each say they are working instead of all at once; forms clear after they create; statement lines no longer duplicate on reload; context rebuild shows its progress

syntaqxcommitted Parent5b077ecBrowse files
15 files+160−1310/15 viewed
+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−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.
+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 )}
+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" />
+10−12
11 import { ArrowUpRight, CreditCard, FileText } 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 { MICROS_PER_DOLLAR, type WorkspaceInvoice } from "@g1t/contracts";
55
1717 TrialCard,
1818 } from "../../components/billing";
1919 import { StatementView } from "../../components/statement";
20−import { Button } from "../../components/ui";
20+import { SubmitButton } from "../../components/ui";
2121 import {
2222 cardCheckResult,
2323 dollars,
209209 loaderData;
210210 const { status } = account;
211211 const owner = role === "owner";
212− const busy = useNavigation().state === "submitting";
213212 const error = actionData as SectionError;
214213 const err = (section: string) => (error && error.section === section ? error.error : undefined);
215214 const standing = planStatus(plan, entitlements);
243242 owner={owner}
244243 enabled={status.enabled}
245244 live={status.live}
246− busy={busy}
247245 meters={meters}
248246 error={err("plan")}
249247 />
250248
251249 {free && status.enabled && (
252− <TrialCard entitlements={entitlements} trialMicros={trialMicros} owner={owner} enabled={status.enabled} busy={busy} error={err("trial")} />
250+ <TrialCard entitlements={entitlements} trialMicros={trialMicros} owner={owner} enabled={status.enabled} error={err("trial")} />
253251 )}
254252
255253 {paying && limit && limit.trust !== "internal" && (
256254 <>
257− <SpendLimitCard limit={limit} owner={owner} busy={busy} error={err("limit")} />
258− <RaiseCard requests={requests} owner={owner} busy={busy} error={err("raise")} />
259− <PrepayCard prepaidMicros={prepaid} owner={owner} live={status.live} busy={busy} error={err("prepay")} />
255+ <SpendLimitCard limit={limit} owner={owner} error={err("limit")} />
256+ <RaiseCard requests={requests} owner={owner} error={err("raise")} />
257+ <PrepayCard prepaidMicros={prepaid} owner={owner} live={status.live} error={err("prepay")} />
260258 </>
261259 )}
262260
263261 {entitlements && (paying || standing.kind === "trial" || standing.kind === "comped" || standing.kind === "enterprise") && (
264− <CapsCard entitlements={entitlements} owner={owner} busy={busy} error={err("caps")} />
262+ <CapsCard entitlements={entitlements} owner={owner} error={err("caps")} />
265263 )}
266264
267− {paying && <OverageCard requests={requests} owner={owner} busy={busy} error={err("overage")} />}
265+ {paying && <OverageCard requests={requests} owner={owner} error={err("overage")} />}
268266
269267 {status.enabled && (
270268 <section className="mb-10 rounded-xl border border-line bg-surface p-5">
286284 </div>
287285 {owner && (
288286 <Form method="post">
289− <Button variant="quiet" type="submit" name="intent" value="portal" disabled={busy}>
287+ <SubmitButton variant="quiet" name="intent" value="portal" pending="Opening Stripe…">
290288 <CreditCard size={14} />
291289 Open Stripe billing
292− </Button>
290+ </SubmitButton>
293291 </Form>
294292 )}
295293 </div>
+10−4
66
77 import type { Route } from "./+types/context";
88 import { page } from "../../lib/meta";
9+import { useRefreshWhile } from "../../lib/refresh";
910 import { CatalogView, ReviewQueue, ScorecardsView, SearchView, fixRule, reviewAction } from "../../components/context";
11+import { SubmitButton } from "../../components/ui";
1012 import { Tabs, TabsList, TabsTrigger } from "../../components/ui/tabs";
1113 import { context as hub, memoryReview, work } from "../../lib/services.server";
1214 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
7072 return (
7173 <fetcher.Form method="post" action={action} className="flex items-center gap-2">
7274 <input type="hidden" name="intent" value="backfill" />
73− <button
74− type="submit"
75− disabled={running || fetcher.state !== "idle"}
75+ <SubmitButton
76+ fetcher={fetcher}
77+ match={{ intent: "backfill" }}
78+ pending="Starting…"
79+ disabled={running}
7680 className="inline-flex items-center gap-1.5 rounded-md border border-line px-2.5 py-1.5 text-xs text-muted hover:border-line-strong hover:text-fg disabled:opacity-50"
7781 >
7882 <RefreshCw size={12} className={running ? "animate-spin" : ""} />
7983 {running ? "Building…" : "Rebuild"}
80− </button>
84+ </SubmitButton>
8185 {fetcher.data?.error && <span className="text-xs text-danger">{fetcher.data.error}</span>}
8286 </fetcher.Form>
8387 );
8993 const navigate = useNavigate();
9094 const backfill = status.backfill;
9195 const running = backfill?.status === "running";
96+ // While it builds, the count of projects done keeps up on its own.
97+ useRefreshWhile(running);
9298 const total = Object.values(status.counts).reduce((sum, n) => sum + (n ?? 0), 0);
9399 return (
94100 <div className="space-y-6">
+4−2
1−import { data, useNavigation } from "react-router";
1+import { data } from "react-router";
22
33 import { RUN_KINDS } from "@g1t/contracts";
44
55 import type { Route } from "./+types/guardrails";
66 import { GuardrailsForm } from "../../components/guardrails";
7+import { usePending } from "../../components/ui";
78 import { settingsFromForm } from "../../lib/guardrails";
89 import { page } from "../../lib/meta";
910 import { guardrails } from "../../lib/services.server";
3738 }
3839
3940 export default function WorkspaceGuardrails({ loaderData, actionData }: Route.ComponentProps) {
40− const saving = useNavigation().state === "submitting";
41+ // Until the saved settings are back on the page, not just until the post is answered.
42+ const saving = usePending();
4143 return (
4244 <GuardrailsForm
4345 view={loaderData.view}
+25−27
99 } from "lucide-react";
1010 import { env } from "cloudflare:workers";
1111 import type { ReactNode } from "react";
12−import { Form, Link, useNavigation } from "react-router";
12+import { Form, Link } from "react-router";
1313
1414 import {
1515 type Connection,
2626 import { page } from "../../lib/meta";
2727 import { trialClosed } from "../../lib/trial";
2828 import { MODEL_CATALOG, ModelCatalog, ModelProviderFields, ProviderMark, ProviderTiles, Routing } from "../../components/model-providers";
29−import { Avatar, Button, CopyLine, ErrorText, Field, Input, TimeAgo } from "../../components/ui";
29+import { Avatar, CopyLine, ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui";
3030 import { CheckboxOption } from "../../components/ui/checkbox";
3131 import { Combobox } from "../../components/ui/combobox";
3232 import { billing, integrations, repos } from "../../lib/services.server";
135135 signingSecret: text(form, "signingSecret"),
136136 secret: text(form, "secret"),
137137 });
138− return { error: updated.ok ? null : updated.error.message };
138+ return updated.ok ? { updated: id } : { error: updated.error.message };
139139 }
140140 const provider = String(form.get("provider") ?? "");
141141 if (!isProvider(provider)) return { error: "Choose what to connect." };
183183 const { slug, role, connections, deliveries, repos: repoNames, adding, free, marginPercent, hostedOpen, hostedPreview, trial, routes } =
184184 loaderData;
185185 const owner = role === "owner";
186− const busy = useNavigation().state === "submitting";
187186 const modelConnections = connections.filter((connection) => connection.kind === "models");
188187 const justConnected = actionData && "connected" in actionData ? actionData.connected : null;
189188 const tested = (actionData && "tested" in actionData ? actionData.tested : null) ?? null;
190189 const error = (actionData && "error" in actionData ? actionData.error : null) ?? null;
190+ const updated = (actionData && "updated" in actionData ? actionData.updated : null) ?? null;
191191
192192 return (
193193 <div>
212212 </p>
213213 </div>
214214 )}
215− <Connections list={modelConnections} owner={owner} busy={busy} tested={tested} deliveries={deliveries} />
215+ <Connections list={modelConnections} owner={owner} tested={tested} updated={updated} deliveries={deliveries} />
216216 {modelConnections.length === 0 && hostedOpen && (
217217 <p className="flex items-center gap-2 rounded-xl border border-line bg-surface px-4 py-3 text-sm text-muted">
218218 <CheckCircle2 size={15} className="shrink-0 text-merged" />
227227 )}
228228 {modelConnections.length > 0 && (
229229 <Routing
230+ // Started again from what is saved whenever that changes, such as a provider disconnected.
231+ key={JSON.stringify([routes, modelConnections.map((c) => c.id)])}
230232 connections={modelConnections}
231233 routes={routes}
232234 hostedOpen={hostedOpen}
233235 marginPercent={marginPercent}
234236 owner={owner}
235− busy={busy}
236237 saved={actionData != null && "routed" in actionData}
237238 />
238239 )}
250251 provider={adding}
251252 slug={slug}
252253 repos={repoNames}
253− busy={busy}
254254 error={actionData && "provider" in actionData ? error : null}
255255 />
256256 )}
261261 <Connections
262262 list={connections.filter((c) => c.kind === "alerts")}
263263 owner={owner}
264− busy={busy}
265264 tested={tested}
265+ updated={updated}
266266 deliveries={deliveries}
267267 />
268268 {owner && <Choices kind="alerts" slug={slug} adding={adding} />}
271271 provider={adding}
272272 slug={slug}
273273 repos={repoNames}
274− busy={busy}
275274 error={actionData && "provider" in actionData ? error : null}
276275 />
277276 )}
282281 <Connections
283282 list={connections.filter((c) => c.kind === "tracker")}
284283 owner={owner}
285− busy={busy}
286284 tested={tested}
285+ updated={updated}
287286 deliveries={deliveries}
288287 />
289288 {owner && <Choices kind="tracker" slug={slug} adding={adding} />}
292291 provider={adding}
293292 slug={slug}
294293 repos={repoNames}
295− busy={busy}
296294 error={actionData && "provider" in actionData ? error : null}
297295 />
298296 )}
320318 function Connections({
321319 list,
322320 owner,
323− busy,
324321 tested,
322+ updated,
325323 deliveries,
326324 }: {
327325 list: Connection[];
328326 owner: boolean;
329− busy: boolean;
330327 tested: { id: string; ok: boolean; message: string } | null;
328+ /** The connection whose secret was just saved. */
329+ updated: string | null;
331330 deliveries: Record<string, Delivery[]>;
332331 }) {
333332 if (list.length === 0) return null;
338337 <ConnectionRow
339338 connection={connection}
340339 owner={owner}
341− busy={busy}
342340 tested={tested}
341+ updated={updated === connection.id}
343342 deliveries={deliveries[connection.id] ?? []}
344343 />
345344 </li>
361360 function ConnectionRow({
362361 connection,
363362 owner,
364− busy,
365363 tested,
364+ updated,
366365 deliveries,
367366 }: {
368367 connection: Connection;
369368 owner: boolean;
370− busy: boolean;
371369 tested: { id: string; ok: boolean; message: string } | null;
370+ updated: boolean;
372371 deliveries: Delivery[];
373372 }) {
374373 const { config } = connection;
401400 {owner && (
402401 <Form method="post" className="flex shrink-0 gap-2">
403402 <input type="hidden" name="id" value={connection.id} />
404− <Button variant="quiet" type="submit" name="intent" value="test" disabled={busy}>
403+ <SubmitButton variant="quiet" name="intent" value="test" match={{ id: connection.id }} pending="Testing…">
405404 Test
406− </Button>
407− <Button variant="quiet" type="submit" name="intent" value="disconnect" disabled={busy} aria-label="Disconnect">
405+ </SubmitButton>
406+ <SubmitButton variant="quiet" name="intent" value="disconnect" match={{ id: connection.id }} pending="Disconnecting…" aria-label="Disconnect">
408407 <X size={14} />
409− </Button>
408+ </SubmitButton>
410409 </Form>
411410 )}
412411 </div>
434433 <Input name="signingSecret" type="password" placeholder="Paste to replace" />
435434 </Field>
436435 </div>
437− <Button type="submit" variant="quiet" disabled={busy}>
436+ <SubmitButton variant="quiet" match={{ intent: "update", id: connection.id }} pending="Saving…">
438437 Save
439− </Button>
438+ </SubmitButton>
439+ {updated && <span className="pb-2 text-sm text-muted">Saved.</span>}
440440 </Form>
441441 )}
442442 {deliveries.length > 0 && (
526526 provider,
527527 slug,
528528 repos,
529− busy,
530529 error,
531530 }: {
532531 provider: Provider;
533532 slug: string;
534533 repos: string[];
535− busy: boolean;
536534 error: string | null;
537535 }) {
538536 const fields: Partial<Record<Provider, ReactNode>> = {
609607 {fields[provider] ?? <ModelProviderFields provider={provider} />}
610608 <ErrorText>{error}</ErrorText>
611609 <div>
612− <Button type="submit" disabled={busy}>
613− {busy ? "Connecting…" : "Connect"}
614− </Button>
610+ <SubmitButton match={{ provider }} pending="Connecting…">
611+ Connect
612+ </SubmitButton>
615613 </div>
616614 </Form>
617615 </section>
+2−2
22
33 import type { Route } from "./+types/new";
44 import { page } from "../../lib/meta";
5−import { Button, ErrorText, Field, Input } from "../../components/ui";
5+import { ErrorText, Field, Input, SubmitButton } from "../../components/ui";
66 import { identity } from "../../lib/services.server";
77 import { assertSameOrigin, nextPath, requireUser } from "../../lib/session.server";
88
7373 <Input name="displayName" maxLength={80} />
7474 </Field>
7575 <ErrorText>{actionData?.error}</ErrorText>
76− <Button type="submit">Create workspace</Button>
76+ <SubmitButton pending="Creating…">Create workspace</SubmitButton>
7777 </Form>
7878 </main>
7979 );
+6−7
11 import { BookOpen, Box, Trash2 } 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 { ECOSYSTEMS, type Ecosystem, type PackageVersion } from "@g1t/contracts";
55
77 import { Markdown } from "../../components/markdown";
88 import { PackageIcon } from "../../components/package-icon";
99 import { ConfirmDialog } from "../../components/repo-lifecycle";
10−import { Button, CopyLine, ErrorText, TimeAgo } from "../../components/ui";
10+import { Button, CopyLine, ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
1111 import { Badge } from "../../components/ui/badge";
1212 import { page } from "../../lib/meta";
1313 import { ECOSYSTEM_LABEL, formatBytes, installCommands, shortDigest } from "../../lib/packages";
293293
294294 function Settings({ detail }: { detail: Route.ComponentProps["loaderData"]["detail"] }) {
295295 const { package: pkg } = detail;
296− const busy = useNavigation().state !== "idle";
297296 return (
298297 <section className="space-y-4 rounded-xl border border-line bg-surface p-5">
299298 <h2 className="text-sm font-semibold">Settings</h2>
318317 <option value="private">Private: workspace members</option>
319318 <option value="public">Public: anyone can pull</option>
320319 </select>
321− <Button type="submit" variant="quiet" disabled={busy}>
320+ <SubmitButton variant="quiet" match={{ intent: "visibility" }} pending="Saving…">
322321 Save
323− </Button>
322+ </SubmitButton>
324323 </Form>
325324 )}
326325 <Form method="post" className="flex flex-wrap items-center gap-2 text-sm">
335334 placeholder="none"
336335 className="h-8 w-56 rounded-md border border-line bg-bg px-2 font-mono text-sm placeholder:text-faint"
337336 />
338− <Button type="submit" variant="quiet" disabled={busy}>
337+ <SubmitButton variant="quiet" match={{ intent: "link" }} pending="Saving…">
339338 {pkg.repo ? "Change link" : "Link"}
340− </Button>
339+ </SubmitButton>
341340 <span className="text-xs text-faint">Empty to unlink. A linked package takes the repository's access.</span>
342341 </Form>
343342 {detail.permissions.delete && (
+23−14
11 import { ArrowUpRight, Users } from "lucide-react";
22 import { useState } from "react";
3−import { Form, Link, data, useNavigation, useSearchParams } from "react-router";
3+import { Form, Link, data, useSearchParams } from "react-router";
44
55 import {
66 BASE_PERMISSIONS,
1212
1313 import type { Route } from "./+types/people";
1414 import { page } from "../../lib/meta";
15−import { Avatar, Button, CopyLine, ErrorText, Field, Input, Pill, TimeAgo } from "../../components/ui";
15+import { Avatar, CopyLine, ErrorText, Field, Input, Pill, SubmitButton, TimeAgo } from "../../components/ui";
1616 import { Badge } from "../../components/ui/badge";
1717 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
1818 import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs";
115115 <Form method="post">
116116 <input type="hidden" name="action" value="remove" />
117117 <input type="hidden" name="member" value={member.username} />
118− <Button variant="quiet" type="submit">
118+ <SubmitButton variant="quiet" match={{ action: "remove", member: member.username }} pending="Removing…">
119119 Remove
120− </Button>
120+ </SubmitButton>
121121 </Form>
122122 )}
123123 </span>
126126 ))}
127127 </ul>
128128 {owner && (
129− <Form method="post" className="mt-6 flex flex-col gap-3 sm:flex-row sm:items-start">
129+ // Empty again once the person is on the list; kept as typed when it failed.
130+ <Form
131+ method="post"
132+ key={`${members.length}:${pending.length}`}
133+ className="mt-6 flex flex-col gap-3 sm:flex-row sm:items-start"
134+ >
135+ <input type="hidden" name="action" value="add" />
130136 <div className="grow">
131137 <Field
132138 label="Add a member"
136142 </Field>
137143 </div>
138144 <div className="sm:pt-[1.625rem]">
139− <Button type="submit">Add</Button>
145+ <SubmitButton match={{ action: "add" }} pending="Adding…">
146+ Add
147+ </SubmitButton>
140148 </div>
141149 </Form>
142150 )}
174182 <Form method="post">
175183 <input type="hidden" name="action" value="revoke-invite" />
176184 <input type="hidden" name="id" value={invite.id} />
177− <Button variant="quiet" type="submit">
185+ <SubmitButton variant="quiet" match={{ action: "revoke-invite", id: invite.id }} pending="Revoking…">
178186 Revoke
179− </Button>
187+ </SubmitButton>
180188 </Form>
181189 </div>
182190 <p className="text-xs text-faint">
190198 )}
191199
192200 <BasePermissionSection
201+ // Starts from the saved choice whenever it changes.
202+ key={base}
193203 base={base}
194204 owner={owner}
195205 slug={params.owner}
245255 saved: boolean;
246256 }) {
247257 const [chosen, setChosen] = useState<BasePermission>(base);
248− const saving = useNavigation().formData?.get("action") === "base-permission";
249258 return (
250259 <section id="base-permission" className="mt-10 scroll-mt-20 border-t border-line pt-8">
251260 <h2 className="font-medium">Base permission</h2>
269278 ))}
270279 </SelectContent>
271280 </Select>
272− <Button type="submit" variant="quiet" disabled={saving || chosen === base}>
273− {saving ? "Saving…" : "Save"}
274− </Button>
281+ <SubmitButton variant="quiet" match={{ action: "base-permission" }} pending="Saving…" disabled={chosen === base}>
282+ Save
283+ </SubmitButton>
275284 {saved && chosen === base && <span className="text-sm text-muted">Saved.</span>}
276285 </div>
277286 <p className="mt-3 text-sm text-muted">{BASE_MEANS[chosen]}</p>
327336 <Form method="post">
328337 <input type="hidden" name="action" value="convert" />
329338 <input type="hidden" name="member" value={person.username} />
330− <Button variant="quiet" type="submit">
339+ <SubmitButton variant="quiet" match={{ action: "convert", member: person.username }} pending="Converting…">
331340 Convert to member
332− </Button>
341+ </SubmitButton>
333342 </Form>
334343 </div>
335344 <ul className="mt-2 flex flex-wrap gap-1.5 pl-10">
+9−2
1616 import { useAddresses } from "../../lib/addresses";
1717 import { AvatarField } from "../../components/avatar-field";
1818 import { DangerAction, DangerZone } from "../../components/danger-zone";
19−import { Button, ErrorText, Field, Input } from "../../components/ui";
19+import { Button, ErrorText, Field, Input, SubmitButton } from "../../components/ui";
2020 import {
2121 AlertDialog,
2222 AlertDialogCancel,
151151 <section>
152152 <h2 className="font-medium">Workspace details</h2>
153153 <Form method="post" className="mt-5 space-y-4">
154+ <input type="hidden" name="intent" value="details" />
154155 <Field
155156 label="Display name"
156157 hint="How people see the workspace: in the sidebar, at the top of its page and in link previews. Up to 80 characters; spaces and capitals are fine."
161162 <Input name="description" maxLength={160} defaultValue={workspace.description ?? ""} />
162163 </Field>
163164 <ErrorText>{actionData && "error" in actionData ? actionData.error : undefined}</ErrorText>
164− <Button type="submit">Save</Button>
165+ <SubmitButton match={{ intent: "details" }} pending="Saving…">
166+ Save
167+ </SubmitButton>
165168 </Form>
166169 </section>
167170
168171 <AddressSection
172+ // A new address starts it again, so the dialog is closed on the renamed page.
173+ key={workspace.slug}
169174 workspace={workspace}
170175 error={actionData && "renameError" in actionData ? actionData.renameError : undefined}
171176 />
172177
173178 {(loaderData.euAvailable || loaderData.residency === "eu") && (
174179 <ResidencySection
180+ // Starts from the saved choice whenever it changes.
181+ key={loaderData.residency}
175182 residency={loaderData.residency}
176183 euAvailable={loaderData.euAvailable}
177184 saved={Boolean(actionData && "saved" in actionData && actionData.saved === "residency")}
+3−3
1−import { data } from "react-router";
2−
31 import type { Route } from "./+types/statement-entries";
42 import { billing } from "../../lib/services.server";
53 import { getViewer } from "../../lib/session.server";
1412 project: query.get("project"),
1513 before: query.get("before"),
1614 });
17− if (!entries.ok) throw data({ error: entries.error.message }, { status: 404 });
15+ // Said beside the line that asked. As a 200: a fetcher shows any other
16+ // status as an error page in place of the whole billing page.
17+ if (!entries.ok) return Response.json({ error: entries.error.message }, { headers: { "cache-control": "no-store" } });
1818 return Response.json(entries.value, { headers: { "cache-control": "no-store" } });
1919 }
+9−5
66 import { page } from "../../lib/meta";
77 import { useAddresses } from "../../lib/addresses";
88 import {
9− Button,
109 CopyLine,
1110 EmptyState,
1211 ErrorText,
1312 Field,
1413 Input,
14+ SubmitButton,
1515 TimeAgo,
1616 } from "../../components/ui";
1717 import { AccessSummary, ExpiryField, ScopeChecklist } from "../../components/token-scopes";
135135 <Form method="post">
136136 <input type="hidden" name="action" value="delete" />
137137 <input type="hidden" name="id" value={token.id} />
138− <Button variant="quiet" type="submit">
138+ <SubmitButton variant="quiet" match={{ action: "delete", id: token.id }} pending="Deleting…">
139139 Delete
140− </Button>
140+ </SubmitButton>
141141 </Form>
142142 )}
143143 </li>
147147 </div>
148148
149149 {role === "owner" ? (
150− <Form method="post" className="mt-6 space-y-5 rounded-xl border border-line p-4 sm:p-5">
150+ // Empty again once the token is made; kept as filled in when it failed.
151+ <Form method="post" key={created?.info.id ?? ""} className="mt-6 space-y-5 rounded-xl border border-line p-4 sm:p-5">
152+ <input type="hidden" name="action" value="create" />
151153 <h2 className="font-medium">New token</h2>
152154 <div className="grid gap-4 sm:grid-cols-[1fr_11rem]">
153155 <Field label="Name" hint="Name it after what will use it.">
156158 <ExpiryField />
157159 </div>
158160 <ScopeChecklist initial={presetScopes("ci")} />
159− <Button type="submit">Create token</Button>
161+ <SubmitButton match={{ action: "create" }} pending="Creating…">
162+ Create token
163+ </SubmitButton>
160164 </Form>
161165 ) : (
162166 <p className="mt-4 text-sm text-muted">