Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| The Runners page shows the machines a workspace's agents and workflow jobs run on, in Workspace under Compute: g1t's cloud beside your own runners, what each is running now with a link to it, what's waiting, this month's machine time and its cost (your own runners' free), where agent work and workflow jobs run, adding a runner, and groups; runner_activity in Actions says what runs where, work handed to your runners keeps its agent run, and the self-hosted runners guide says how. | 1 | import { ArrowRight, Bot, Boxes, Cloud, Cpu, Laptop, Plus, ServerCog, Workflow } from "lucide-react"; |
| 2 | import type { ReactNode } from "react"; | |
| 3 | import { Link } from "react-router"; | |
| 4 | ||
| 5 | import { RUN_KIND_LABEL, type Runner, type RunKind } from "@g1t/contracts"; | |
| 6 | ||
| 7 | import { dollars } from "../lib/billing"; | |
| 8 | import { type CloudWork, machineTime, perMinute } from "../lib/runners"; | |
| 9 | import type { RunnersAction, RunnersPageData } from "../lib/runners.server"; | |
| 10 | import { Groups, NewRunner, RunnerRow, RunnerSettingsForm, STATUS } from "./runners"; | |
| 11 | import { EmptyState, ErrorText, TimeAgo } from "./ui"; | |
| 12 | import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; | |
| 13 | ||
| 14 | /** The most of what runs on g1t's cloud the card lists before saying how many more. */ | |
| 15 | const CLOUD_SHOWN = 6; | |
| 16 | ||
| 17 | /** A small label that something is coming. */ | |
| 18 | function Coming() { | |
| 19 | return <span className="shrink-0 rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">Coming</span>; | |
| 20 | } | |
| 21 | ||
| 22 | function Section({ id, title, about, action, children }: { id?: string; title: string; about?: ReactNode; action?: ReactNode; children: ReactNode }) { | |
| 23 | return ( | |
| 24 | <section id={id} className="scroll-mt-6 space-y-3"> | |
| 25 | <div className="flex flex-wrap items-end justify-between gap-x-4 gap-y-1"> | |
| 26 | <div className="min-w-0"> | |
| 27 | <h2 className="text-base font-semibold tracking-tight">{title}</h2> | |
| 28 | {about && <div className="mt-0.5 text-sm text-muted">{about}</div>} | |
| 29 | </div> | |
| 30 | {action} | |
| 31 | </div> | |
| 32 | {children} | |
| 33 | </section> | |
| 34 | ); | |
| 35 | } | |
| 36 | ||
| 37 | /** One figure in a place's card: a label and its value. */ | |
| 38 | function Figure({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { | |
| 39 | return ( | |
| 40 | <div className="flex items-baseline justify-between gap-3 py-2 text-sm"> | |
| 41 | <dt className="text-muted"> | |
| 42 | {hint ? ( | |
| 43 | <Tooltip> | |
| 44 | <TooltipTrigger asChild> | |
| 45 | <span className="cursor-help underline decoration-line-strong decoration-dotted underline-offset-4">{label}</span> | |
| 46 | </TooltipTrigger> | |
| 47 | <TooltipContent>{hint}</TooltipContent> | |
| 48 | </Tooltip> | |
| 49 | ) : ( | |
| 50 | label | |
| 51 | )} | |
| 52 | </dt> | |
| 53 | <dd className="min-w-0 text-right text-fg tabular-nums">{children}</dd> | |
| 54 | </div> | |
| 55 | ); | |
| 56 | } | |
| 57 | ||
| 58 | function Unknown({ what }: { what: string }) { | |
| 59 | return <span className="text-faint">{what} didn't answer</span>; | |
| 60 | } | |
| 61 | ||
| 62 | function PlaceCard({ icon, title, tag, about, headline, children }: { icon: ReactNode; title: string; tag: ReactNode; about: string; headline: ReactNode; children: ReactNode }) { | |
| 63 | return ( | |
| 64 | <section className="flex min-w-0 flex-col rounded-2xl border border-line bg-surface"> | |
| 65 | <header className="px-5 pt-4"> | |
| 66 | <div className="flex items-center gap-2"> | |
| 67 | <span className="grid size-7 shrink-0 place-items-center rounded-lg bg-raised text-fg-soft">{icon}</span> | |
| 68 | <h2 className="text-sm font-semibold">{title}</h2> | |
| 69 | <span className="ml-auto">{tag}</span> | |
| 70 | </div> | |
| 71 | <p className="mt-2 text-sm leading-relaxed text-muted">{about}</p> | |
| 72 | </header> | |
| 73 | <div className="px-5 pt-4">{headline}</div> | |
| 74 | <dl className="mt-2 divide-y divide-line border-t border-line px-5 py-1">{children}</dl> | |
| 75 | </section> | |
| 76 | ); | |
| 77 | } | |
| 78 | ||
| 79 | function kindLabel(work: CloudWork): string { | |
| 80 | if (work.kind === "workflow") return "Workflow job"; | |
| 81 | return RUN_KIND_LABEL[work.detail as RunKind] ?? "Agent run"; | |
| 82 | } | |
| 83 | ||
| 84 | function CloudList({ data }: { data: RunnersPageData }) { | |
| 85 | const cloud = data.cloud; | |
| 86 | if (!cloud) return <p className="px-5 py-4 text-sm text-muted">What runs on g1t's cloud can't be read right now.</p>; | |
| 87 | if (cloud.running.length === 0) { | |
| 88 | return ( | |
| 89 | <p className="px-5 py-4 text-sm text-muted"> | |
| 90 | Nothing is running here now. A sandbox starts for each agent run or job and stops when it ends{cloud.queued > 0 ? `; ${cloud.queued} waiting to start` : ""}. | |
| 91 | </p> | |
| 92 | ); | |
| 93 | } | |
| 94 | const shown = cloud.running.slice(0, CLOUD_SHOWN); | |
| 95 | const rest = cloud.running.length - shown.length; | |
| 96 | return ( | |
| 97 | <ul className="divide-y divide-line"> | |
| 98 | {shown.map((work) => ( | |
| 99 | <li key={`${work.kind}:${work.id}`} className="flex items-center gap-3 px-5 py-2.5"> | |
| 100 | <span className="grid size-6 shrink-0 place-items-center rounded-md bg-info/12 text-info" aria-hidden> | |
| 101 | {work.kind === "agent" ? <Bot size={13} /> : <Workflow size={13} />} | |
| 102 | </span> | |
| 103 | <div className="min-w-0 grow"> | |
| 104 | <Link to={work.href} className="block truncate text-sm text-fg hover:underline"> | |
| 105 | {work.title} | |
| 106 | </Link> | |
| 107 | <p className="truncate text-xs text-muted"> | |
| 108 | {kindLabel(work)} · {work.repo} | |
| 109 | </p> | |
| 110 | </div> | |
| 111 | {work.startedAt && ( | |
| 112 | <span className="shrink-0 text-xs text-faint"> | |
| 113 | <TimeAgo at={work.startedAt} /> | |
| 114 | </span> | |
| 115 | )} | |
| 116 | </li> | |
| 117 | ))} | |
| 118 | {(rest > 0 || cloud.more || cloud.queued > 0) && ( | |
| 119 | <li className="px-5 py-2.5 text-xs text-muted"> | |
| 120 | {[rest > 0 || cloud.more ? `${rest > 0 ? rest : "More"}${cloud.more ? "+" : ""} more running` : null, cloud.queued > 0 ? `${cloud.queued} waiting to start` : null] | |
| 121 | .filter(Boolean) | |
| 122 | .join(" · ")} | |
| 123 | </li> | |
| 124 | )} | |
| 125 | </ul> | |
| 126 | ); | |
| 127 | } | |
| 128 | ||
| 129 | function StatusCounts({ runners }: { runners: Runner[] }) { | |
| 130 | const counts = { busy: 0, online: 0, offline: 0 } as Record<Runner["status"], number>; | |
| 131 | for (const runner of runners) counts[runner.status] += 1; | |
| 132 | return ( | |
| 133 | <ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted"> | |
| 134 | {(["busy", "online", "offline"] as const).map((status) => ( | |
| 135 | <li key={status} className="flex items-center gap-1.5"> | |
| 136 | <span className={`size-2 rounded-full ${STATUS[status].dot}`} aria-hidden /> | |
| 137 | <span className="text-fg tabular-nums">{counts[status]}</span> {STATUS[status].label.toLowerCase()} | |
| 138 | </li> | |
| 139 | ))} | |
| 140 | </ul> | |
| 141 | ); | |
| 142 | } | |
| 143 | ||
| 144 | /** The workspace's Runners page: g1t's cloud and its own runners, side by side. */ | |
| 145 | export function RunnersPage({ data, action, slug }: { data: RunnersPageData; action: RunnersAction | undefined; slug: string }) { | |
| 146 | const own = data.runners; | |
| 147 | const online = own.filter((runner) => runner.status !== "offline").length; | |
| 148 | const cost = data.cost; | |
| 149 | const running = data.cloud?.running.length ?? null; | |
| 150 | const usage = `/${slug}/-/usage`; | |
| 151 | return ( | |
| 152 | <div className="space-y-10"> | |
| 153 | <header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> | |
| 154 | <div className="min-w-0"> | |
| 155 | <h1 className="text-[1.75rem] leading-tight font-semibold tracking-tight">Runners</h1> | |
| 156 | <p className="mt-1.5 max-w-2xl text-sm leading-relaxed text-muted"> | |
| 157 | The machines your agents and workflow jobs run on. g1t's cloud needs nothing set up; your own runners are any machine with{" "} | |
| 158 | <code className="font-mono text-fg-soft">g1t-runner</code> on it, and time on them is free. | |
| 159 | </p> | |
| 160 | </div> | |
| 161 | <a | |
| 162 | href="#add" | |
| 163 | className="inline-flex h-9 shrink-0 items-center gap-1.5 self-start rounded-lg bg-accent px-3.5 text-sm font-medium text-bg transition-colors hover:bg-accent-hover" | |
| 164 | > | |
| 165 | <Plus size={15} /> | |
| 166 | Add a runner | |
| 167 | </a> | |
| 168 | </header> | |
| 169 | ||
| 170 | {(action?.notice || action?.error || data.error) && ( | |
| 171 | <div> | |
| 172 | {action?.notice && <p className="text-sm text-success">{action.notice}</p>} | |
| 173 | <ErrorText>{action?.error ?? data.error ?? null}</ErrorText> | |
| 174 | </div> | |
| 175 | )} | |
| 176 | ||
| 177 | <div className="grid items-start gap-5 lg:grid-cols-2"> | |
| 178 | <PlaceCard | |
| 179 | icon={<Cloud size={15} />} | |
| 180 | title="g1t cloud" | |
| 181 | tag={<span className="rounded-full bg-raised px-2 py-0.5 text-xs text-fg-soft">Managed by g1t</span>} | |
| 182 | about="A fresh sandbox for every agent run and workflow job, stopped when it ends. Billed by the second at the price below." | |
| 183 | headline={ | |
| 184 | <div className="flex items-baseline gap-2"> | |
| 185 | <span className="text-3xl font-semibold tracking-tight tabular-nums">{running ?? "–"}</span> | |
| 186 | <span className="text-sm text-muted">running now</span> | |
| 187 | </div> | |
| 188 | } | |
| 189 | > | |
| 190 | <Figure label="This month" hint="Agent sandbox time and sandbox time on your usage, at price, from the first of the month."> | |
| 191 | {cost ? ( | |
| 192 | <> | |
| 193 | {machineTime(cost.cloudSeconds)} · {dollars(cost.cloudMicros)} | |
| 194 | </> | |
| 195 | ) : ( | |
| 196 | <Unknown what="Billing" /> | |
| 197 | )} | |
| 198 | </Figure> | |
| 199 | {data.prices ? ( | |
| 200 | <Figure label="Price" hint={`From g1t's price book: ${data.prices.map((price) => price.title).join("; ")}.`}> | |
| 201 | <span className="flex flex-wrap justify-end gap-x-1"> | |
| 202 | {data.prices.map((price, at) => ( | |
| 203 | <span key={price.meter} className="whitespace-nowrap"> | |
| 204 | {at > 0 && "+ "} | |
| 205 | {perMinute(price.perMinuteMicros)}/{price.unit} | |
| 206 | </span> | |
| 207 | ))} | |
| 208 | </span> | |
| 209 | </Figure> | |
| 210 | ) : ( | |
| 211 | <Figure label="Price"> | |
| 212 | <Unknown what="Billing" /> | |
| 213 | </Figure> | |
| 214 | )} | |
| 215 | {data.agentCap != null && ( | |
| 216 | <Figure label="Agent runs at once" hint="Your plan's cap on agent runs at the same time."> | |
| 217 | up to {data.agentCap} | |
| 218 | </Figure> | |
| 219 | )} | |
| 220 | </PlaceCard> | |
| 221 | ||
| 222 | <PlaceCard | |
| 223 | icon={<ServerCog size={15} />} | |
| 224 | title="Your runners" | |
| 225 | tag={<span className="rounded-full bg-success/12 px-2 py-0.5 text-xs font-medium text-success">Free</span>} | |
| 226 | about="Your own machines: Linux, macOS or Windows, anywhere they can reach g1t. They only connect out, and take the work whose labels they have." | |
| 227 | headline={ | |
| 228 | <div className="space-y-2"> | |
| 229 | <div className="flex items-baseline gap-2"> | |
| 230 | <span className="text-3xl font-semibold tracking-tight tabular-nums">{own.length === 0 ? 0 : online}</span> | |
| 231 | <span className="text-sm text-muted">{own.length === 0 ? "runners yet" : `of ${own.length} online`}</span> | |
| 232 | </div> | |
| 233 | {own.length > 0 && <StatusCounts runners={own} />} | |
| 234 | </div> | |
| 235 | } | |
| 236 | > | |
| 237 | <Figure label="This month" hint="Self-hosted runner time on your usage, from the first of the month. It is never charged."> | |
| 238 | {cost ? ( | |
| 239 | <> | |
| 240 | {machineTime(cost.ownSeconds)} · {dollars(0)} | |
| 241 | </> | |
| 242 | ) : ( | |
| 243 | <Unknown what="Billing" /> | |
| 244 | )} | |
| 245 | </Figure> | |
| 246 | <Figure label="Waiting for one of them"> | |
| 247 | {data.waiting ? ( | |
| 248 | data.waiting.jobs + data.waiting.tasks === 0 ? ( | |
| 249 | "Nothing" | |
| 250 | ) : ( | |
| 251 | [ | |
| 252 | data.waiting.jobs > 0 ? `${data.waiting.jobs} ${data.waiting.jobs === 1 ? "job" : "jobs"}` : null, | |
| 253 | data.waiting.tasks > 0 ? `${data.waiting.tasks} agent ${data.waiting.tasks === 1 ? "run" : "runs"}` : null, | |
| 254 | ] | |
| 255 | .filter(Boolean) | |
| 256 | .join(", ") | |
| 257 | ) | |
| 258 | ) : ( | |
| 259 | <Unknown what="Actions" /> | |
| 260 | )} | |
| 261 | </Figure> | |
| 262 | <Figure label="Takes agent work"> | |
| 263 | {data.settings ? (data.settings.agentsOnSelfHosted ? `Yes, labelled ${data.settings.agentLabels.join(", ")}` : "No") : "–"} | |
| 264 | </Figure> | |
| 265 | </PlaceCard> | |
| 266 | </div> | |
| 267 | ||
| 268 | <div className="grid items-start gap-5 lg:grid-cols-2"> | |
| 269 | <Section title="On g1t cloud now" about="Agent runs and workflow jobs in g1t's sandboxes."> | |
| 270 | <div className="rounded-2xl border border-line bg-surface"> | |
| 271 | <CloudList data={data} /> | |
| 272 | </div> | |
| 273 | </Section> | |
| 274 | <Section | |
| 275 | title="Machine time" | |
| 276 | about={cost ? `${cost.from} to ${cost.until}, as your usage shows it.` : "From your usage."} | |
| 277 | action={ | |
| 278 | <Link to={usage} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg"> | |
| 279 | Usage | |
| 280 | <ArrowRight size={12} /> | |
| 281 | </Link> | |
| 282 | } | |
| 283 | > | |
| 284 | <TimeSplit data={data} /> | |
| 285 | </Section> | |
| 286 | </div> | |
| 287 | ||
| 288 | <Section title="Your runners" about={own.length === 0 ? "None yet." : `${online} of ${own.length} online. A runner that hasn't been heard from for 90 seconds is offline.`}> | |
| 289 | {own.length === 0 ? ( | |
| 290 | <EmptyState title="No runners of your own yet"> | |
| 291 | <a href="#add" className="underline underline-offset-2"> | |
| 292 | Add one | |
| 293 | </a>{" "} | |
| 294 | to run jobs and agents on your hardware. It connects out to g1t; nothing needs to reach the machine. | |
| 295 | </EmptyState> | |
| 296 | ) : ( | |
| 297 | <ul className="divide-y divide-line rounded-2xl border border-line bg-surface"> | |
| 298 | {own.map((runner) => ( | |
| 299 | <RunnerRow key={runner.id} runner={runner} manage /> | |
| 300 | ))} | |
| 301 | </ul> | |
| 302 | )} | |
| 303 | </Section> | |
| 304 | ||
| 305 | <Section title="Where work runs" about="For every project in the workspace, unless a project says otherwise in its own settings."> | |
| 306 | <div className="grid gap-3 md:grid-cols-3"> | |
| 307 | <WhereCard icon={<Workflow size={15} />} title="Workflow jobs"> | |
| 308 | Each job's <code className="font-mono text-fg-soft">runs-on</code> decides. <code className="font-mono text-fg-soft">ubuntu-latest</code>,{" "} | |
| 309 | <code className="font-mono text-fg-soft">g1t-4core</code> and other Linux labels run on g1t cloud;{" "} | |
| 310 | <code className="font-mono text-fg-soft">self-hosted</code> and your own labels run on your runners. | |
| 311 | </WhereCard> | |
| 312 | <WhereCard icon={<Bot size={15} />} title="Agents"> | |
| 313 | {data.settings?.agentsOnSelfHosted | |
| 314 | ? `Agent runs, checks, reviews and the merge queue run on your runners labelled ${data.settings.agentLabels.join(", ")}.` | |
| 315 | : "Agent runs, checks, reviews and the merge queue run on g1t cloud. Switch them to your runners below."} | |
| 316 | </WhereCard> | |
| 317 | <WhereCard icon={<Laptop size={15} />} title="Your desktop" coming> | |
| 318 | The desktop app will include a runner: for a logged-in browser, a simulator or a tool only your network reaches. It asks before each task. | |
| 319 | </WhereCard> | |
| 320 | </div> | |
| 321 | <RunnerSettingsForm | |
| 322 | key={data.settings ? `${data.settings.agentsOnSelfHosted}:${data.settings.forkPullRequests}:${data.settings.agentLabels.join(",")}` : "none"} | |
| 323 | data={data} | |
| 324 | manage | |
| 325 | scope="workspace" | |
| 326 | /> | |
| 327 | </Section> | |
| 328 | ||
| 329 | <Section | |
| 330 | id="add" | |
| 331 | title="Add a runner" | |
| 332 | about="Download g1t-runner, register it with a token made here, and start it. Linux, macOS and Windows, x64 and arm64, or Docker." | |
| 333 | > | |
| 334 | <NewRunner token={action?.token ?? null} groups={data.groups} repoScoped={false} /> | |
| 335 | </Section> | |
| 336 | ||
| 337 | <Section title="Groups" about="Which repositories may use which runners. A runner joins the default group, every repository, unless its token names another."> | |
| 338 | <Groups groups={data.groups} repositories={data.repositories} manage action={action} /> | |
| 339 | </Section> | |
| 340 | ||
| 341 | <Section title="Coming to runners"> | |
| 342 | <ul className="divide-y divide-line rounded-2xl border border-line bg-surface"> | |
| 343 | <ComingRow icon={<Cpu size={15} />} title="Sessions that persist"> | |
| 344 | An agent keeps its runner's disk between tasks, its checkouts, caches and notes, and picks up where it left off. | |
| 345 | </ComingRow> | |
| 346 | <ComingRow icon={<Boxes size={15} />} title="An official agent image"> | |
| 347 | One image with git, Node and the agent's tools, so agent work runs on any runner with Docker without an image of your own. | |
| 348 | </ComingRow> | |
| 349 | </ul> | |
| 350 | </Section> | |
| 351 | </div> | |
| 352 | ); | |
| 353 | } | |
| 354 | ||
| 355 | function WhereCard({ icon, title, coming, children }: { icon: ReactNode; title: string; coming?: boolean; children: ReactNode }) { | |
| 356 | return ( | |
| 357 | <div className={`rounded-2xl border p-4 ${coming ? "border-dashed border-line" : "border-line bg-surface"}`}> | |
| 358 | <h3 className="flex items-center gap-2 text-sm font-medium"> | |
| 359 | <span className="text-muted">{icon}</span> | |
| 360 | {title} | |
| 361 | {coming && <span className="ml-auto"><Coming /></span>} | |
| 362 | </h3> | |
| 363 | <p className="mt-2 text-sm leading-relaxed text-muted">{children}</p> | |
| 364 | </div> | |
| 365 | ); | |
| 366 | } | |
| 367 | ||
| 368 | function ComingRow({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) { | |
| 369 | return ( | |
| 370 | <li className="flex items-start gap-3 px-4 py-3"> | |
| 371 | <span className="mt-0.5 text-muted">{icon}</span> | |
| 372 | <div className="min-w-0 grow"> | |
| 373 | <p className="flex items-center gap-2 text-sm font-medium"> | |
| 374 | {title} | |
| 375 | <Coming /> | |
| 376 | </p> | |
| 377 | <p className="mt-0.5 text-sm text-muted">{children}</p> | |
| 378 | </div> | |
| 379 | </li> | |
| 380 | ); | |
| 381 | } | |
| 382 | ||
| 383 | /** This month's machine time, g1t cloud's against the workspace's own, as one bar. */ | |
| 384 | function TimeSplit({ data }: { data: RunnersPageData }) { | |
| 385 | const cost = data.cost; | |
| 386 | if (!cost) { | |
| 387 | return ( | |
| 388 | <div className="rounded-2xl border border-line bg-surface px-5 py-4 text-sm text-muted">Billing didn't answer, so this month's time can't be shown right now.</div> | |
| 389 | ); | |
| 390 | } | |
| 391 | const total = cost.cloudSeconds + cost.ownSeconds; | |
| 392 | return ( | |
| 393 | <div className="rounded-2xl border border-line bg-surface px-5 py-4"> | |
| 394 | {total === 0 ? ( | |
| 395 | <p className="text-sm text-muted">No machine time yet this month.</p> | |
| 396 | ) : ( | |
| 397 | <div className="flex h-2.5 gap-[2px] overflow-hidden rounded-full" role="img" aria-label="This month's machine time by where it ran"> | |
| 398 | {cost.cloudSeconds > 0 && <span className="h-full min-w-[3px] bg-info" style={{ flexGrow: cost.cloudSeconds }} />} | |
| 399 | {cost.ownSeconds > 0 && <span className="h-full min-w-[3px] bg-success" style={{ flexGrow: cost.ownSeconds }} />} | |
| 400 | </div> | |
| 401 | )} | |
| 402 | <ul className="mt-3 space-y-2 text-sm"> | |
| 403 | <li className="flex items-center gap-2"> | |
| 404 | <span className="size-2 shrink-0 rounded-[2px] bg-info" aria-hidden /> | |
| 405 | <span className="min-w-0 grow truncate text-fg-soft">g1t cloud</span> | |
| 406 | <span className="text-muted tabular-nums">{machineTime(cost.cloudSeconds)}</span> | |
| 407 | <span className="w-20 text-right text-fg tabular-nums">{dollars(cost.cloudMicros)}</span> | |
| 408 | </li> | |
| 409 | <li className="flex items-center gap-2"> | |
| 410 | <span className="size-2 shrink-0 rounded-[2px] bg-success" aria-hidden /> | |
| 411 | <span className="min-w-0 grow truncate text-fg-soft">Your runners</span> | |
| 412 | <span className="text-muted tabular-nums">{machineTime(cost.ownSeconds)}</span> | |
| 413 | <span className="w-20 text-right text-fg tabular-nums">{dollars(0)}</span> | |
| 414 | </li> | |
| 415 | </ul> | |
| 416 | <p className="mt-4 text-[0.6875rem] leading-relaxed text-faint"> | |
| 417 | {cost.free ? "Usage at cost: g1t charges nothing for it now." : "At price, before your plan's included usage or credit paid for any of it."} Models are separate. | |
| 418 | </p> | |
| 419 | </div> | |
| 420 | ); | |
| 421 | } |