Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

925 linesCodeBlameRaw
1/**
2 * Getting started: the setup guide that opens on a workspace's Home for
3 * its owners until they finish or dismiss it, and opens again from
4 * Settings → General, the AI page and the command palette.
5 *
6 * A dialog with a rail of steps beside the step at hand (on a phone, the
7 * sheet with the step's place in the guide above it). Each step's tick is
8 * the workspace's own state (lib/onboarding.ts); adding an AI provider and
9 * choosing Ask's model post to the AI page's own action, so they are the
10 * same checks the AI page makes, and the page's loaders read the new state
11 * after. Closing the dialog, or finishing it, dismisses it for the person
12 * (`-/onboarding`, identity's `set_onboarding`), so it stops opening by
13 * itself; nothing else is lost.
14 */
15import {
16 ArrowLeft,
17 ArrowRight,
18 Bot,
19 Check,
20 CircleDashed,
21 Code2,
22 ExternalLink,
23 GitBranch,
24 KeyRound,
25 Laptop,
26 Plus,
27 Rocket,
28 Sparkles,
29 Users,
30} from "lucide-react";
31import { type ReactNode, useEffect, useRef, useState } from "react";
32import { Link, useFetcher } from "react-router";
33
34import { type AiConnection, type AiProvider, type OnboardingStep, AI_PROVIDERS } from "@g1t/contracts";
35
36import { AI_PROVIDER_GROUPS, AI_PROVIDER_LOOKS, aiProvidersIn } from "../lib/ai-providers";
37import { autoOffered, defaultModel, modelOptions } from "../lib/ask-model";
38import { cn } from "../lib/cn";
39import {
40 type OnboardingStepView,
41 type OnboardingView,
42 STEP_ORDER,
43 finished,
44 firstOpenStep,
45 opensByItself,
46 progressOf,
47} from "../lib/onboarding";
48import { AiProviderMark } from "./connectors";
49import { ModelProviderFields } from "./model-providers";
50import { ButtonLink, CopyLine, ErrorText, Field, Input, SubmitButton } from "./ui";
51import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
52import { Avatar } from "./ui/avatar";
53import { Badge } from "./ui/badge";
54import { Button } from "./ui/button";
55import { Card } from "./ui/card";
56import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
57import { Combobox } from "./ui/combobox";
58import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./ui/dialog";
59import { Hint } from "./ui/hint";
60import { Progress } from "./ui/progress";
61import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "./ui/select";
62import { Skeleton } from "./ui/skeleton";
63import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
64
65/** Each step's icon and what it is for, under its title. */
66const ABOUT: Record<OnboardingStep, { icon: ReactNode; lead: string }> = {
67 ai: {
68 icon: <Sparkles size={16} />,
69 lead: "g1t works with models on your own keys: Ask and @g1t in Chat answer with one, and an Anthropic key starts sessions on issues. Add a frontier lab, a platform, or a model you host yourself. g1t bills nothing for it; what it costs is on your provider's bill.",
70 },
71 ask: {
72 icon: <Bot size={16} />,
73 lead: "Choose which provider and model answer Ask in the top bar and @g1t in Chat. Auto picks a fast model for lookups and a stronger one when a question needs it.",
74 },
75 code: {
76 icon: <Code2 size={16} />,
77 lead: "Start a project with a new repository, or bring repositories you already have across from GitHub. Issues, pull requests, checks and deployments live beside the code.",
78 },
79 harness: {
80 icon: <Laptop size={16} />,
81 lead: "Your coding agent works through g1t: it claims issues, records its sessions and opens pull requests. Install the g1t CLI and the Claude Code plugin, and sign both in.",
82 },
83 people: {
84 icon: <Users size={16} />,
85 lead: "Bring your team in: they get the workspace's projects, chat and artifacts, with the roles you give them.",
86 },
87 runner: {
88 icon: <Rocket size={16} />,
89 lead: "Run workflows and sessions on your own machine: the desktop app registers it as a runner, and g1t's cloud takes over whenever it's off.",
90 },
91};
92
93/** The step's tick in the rail: done, skipped, or its number. */
94function StepMark({ step, index }: { step: OnboardingStepView; index: number }) {
95 if (step.done) {
96 return (
97 <Badge tone="success" shape="mark" aria-label="Done">
98 <Check size={13} strokeWidth={2.5} />
99 </Badge>
100 );
101 }
102 if (step.skipped) {
103 return (
104 <Badge shape="mark" aria-label="Skipped">
105 <CircleDashed size={13} />
106 </Badge>
107 );
108 }
109 return (
110 <Badge tone="accent" shape="mark" className="tabular-nums">
111 {index + 1}
112 </Badge>
113 );
114}
115
116/** Getting started's whole state, from what the page loaded or, opened from elsewhere, from `-/onboarding`. */
117function useView(slug: string, initial: OnboardingView | null, open: boolean): OnboardingView | null {
118 const fetcher = useFetcher<{ onboarding: OnboardingView }>({ key: `onboarding-${slug}` });
119 const asked = useRef(false);
120 useEffect(() => {
121 if (!open || initial || asked.current) return;
122 asked.current = true;
123 fetcher.load(`/${slug}/-/onboarding`);
124 }, [open, initial, slug, fetcher]);
125 return fetcher.data?.onboarding ?? initial;
126}
127
128/**
129 * The dialog. `view` is what the page loaded, or null to read it as it
130 * opens. Closing it dismisses it for the person; it can always be opened
131 * again.
132 */
133export function OnboardingDialog({
134 slug,
135 view: loaded,
136 open,
137 onOpenChange,
138}: {
139 slug: string;
140 view: OnboardingView | null;
141 open: boolean;
142 onOpenChange: (open: boolean) => void;
143}) {
144 const view = useView(slug, loaded, open);
145 const saver = useFetcher({ key: `onboarding-save-${slug}` });
146 const [step, setStep] = useState<OnboardingStep | null>(null);
147 const current = step ?? (view ? firstOpenStep(view.steps) : "ai");
148
149 const close = () => {
150 if (view && view.state.dismissed_at == null) {
151 void saver.submit({ intent: "dismiss" }, { method: "post", action: `/${slug}/-/onboarding` });
152 }
153 onOpenChange(false);
154 };
155
156 return (
157 <Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
158 <DialogContent
159 size="split"
160 aria-describedby="onboarding-about"
161 onOpenAutoFocus={(event) => {
162 // On a phone the sheet itself takes focus, not the first step's
163 // segment (a ring for no reason) or a field (the keyboard).
164 if (!matchMedia("(max-width: 639px)").matches) return;
165 event.preventDefault();
166 (event.currentTarget as HTMLElement).focus();
167 }}
168 >
169 {view ? (
170 <Guide view={view} step={current} onStep={setStep} onFinish={close} />
171 ) : (
172 <>
173 <DialogTitle className="sr-only">Getting started</DialogTitle>
174 <DialogDescription id="onboarding-about" className="sr-only">
175 Loading the steps.
176 </DialogDescription>
177 <GuideSkeleton />
178 </>
179 )}
180 </DialogContent>
181 </Dialog>
182 );
183}
184
185function GuideSkeleton() {
186 return (
187 <>
188 <div className="space-y-3 border-line p-5 max-sm:hidden sm:border-r">
189 <Skeleton className="h-5 w-40" />
190 <Skeleton className="h-2 w-full rounded-full" />
191 {STEP_ORDER.map((key) => (
192 <Skeleton key={key} className="h-11 w-full rounded-lg" />
193 ))}
194 </div>
195 <div aria-busy="true" className="space-y-3 p-6">
196 <Skeleton className="h-6 w-56" />
197 <Skeleton className="h-4 w-full" />
198 <Skeleton className="h-4 w-4/5" />
199 <Skeleton className="mt-6 h-40 w-full rounded-xl" />
200 </div>
201 </>
202 );
203}
204
205function Guide({
206 view,
207 step,
208 onStep,
209 onFinish,
210}: {
211 view: OnboardingView;
212 step: OnboardingStep;
213 onStep: (step: OnboardingStep) => void;
214 onFinish: () => void;
215}) {
216 const { steps, slug } = view;
217 const progress = progressOf(steps);
218 const done = finished(steps);
219 const index = Math.max(0, steps.findIndex((s) => s.key === step));
220 const here = steps[index]!;
221 return (
222 <Tabs value={step} onValueChange={(value) => onStep(value as OnboardingStep)} orientation="vertical" className="contents">
223 {/* The rail: where you are in the guide. A phone has the sheet's header instead. */}
224 <aside className="flex min-h-0 flex-col border-line bg-bg/40 max-sm:hidden sm:border-r">
225 <div className="space-y-3 p-5 pb-4">
226 <div className="flex items-center gap-2.5">
227 <Avatar name={slug} size={28} square />
228 <div className="min-w-0">
229 <p className="text-xs text-muted">Getting started</p>
230 <p className="truncate text-sm font-semibold tracking-tight">{view.name}</p>
231 </div>
232 </div>
233 <div className="space-y-1.5">
234 <Progress value={progress.percent} aria-label="Setup progress" indicatorClassName={done ? "bg-success" : undefined} />
235 <p className="text-xs text-muted tabular-nums">{done ? "All set" : `${progress.settled} of ${progress.total} done`}</p>
236 </div>
237 </div>
238 <TabsList variant="rail" aria-label="Steps" className="min-h-0 grow overflow-y-auto px-2 pb-2">
239 {steps.map((s, i) => (
240 <TabsTrigger key={s.key} value={s.key} variant="rail">
241 <StepMark step={s} index={i} />
242 <span className="min-w-0">
243 <span className="block font-medium text-fg">{s.title}</span>
244 <span className="block truncate text-xs text-muted">{s.skipped ? "Skipped" : s.summary}</span>
245 </span>
246 </TabsTrigger>
247 ))}
248 </TabsList>
249 <p className="border-t border-line px-5 py-3 text-xs leading-relaxed text-faint">
250 Closing this puts it away. Open it again from Settings or AI.
251 </p>
252 </aside>
253
254 {/* The step at hand. On a touch screen its buttons are a finger's
255 size (`h-8` and `h-7` ones grow to 40 and 36 pixels). */}
256 <section className="flex min-h-0 flex-col pointer-coarse:[&_[data-slot=button].h-7]:h-9 pointer-coarse:[&_[data-slot=button].h-8]:h-10 pointer-coarse:**:[[role=tab]]:py-2">
257 <StepStrip view={view} index={index} onStep={onStep} done={done} />
258 {/* Keyed by step, so each one starts at its top. */}
259 <div key={step} className="min-h-0 grow overflow-y-auto overscroll-contain p-6 max-sm:px-4 max-sm:pt-5">
260 {steps.map((s) => (
261 <TabsContent key={s.key} value={s.key} className="mt-0 space-y-5">
262 <header className="space-y-2 pr-8">
263 <div className="flex items-center gap-2 text-accent">
264 {ABOUT[s.key].icon}
265 <span className="text-xs font-medium tracking-wide uppercase">{s.optional ? "Optional" : "Start here"}</span>
266 </div>
267 <DialogTitle className="text-xl">{s.title}</DialogTitle>
268 <DialogDescription id={s.key === step ? "onboarding-about" : undefined} className="leading-relaxed">
269 {ABOUT[s.key].lead}
270 </DialogDescription>
271 </header>
272 {s.done && (
273 <Alert tone="success" role="status">
274 <AlertTitle className="flex items-center gap-1.5">
275 <Check size={14} className="text-success" /> Done
276 </AlertTitle>
277 <AlertDescription>{s.summary}.</AlertDescription>
278 </Alert>
279 )}
280 {s.skipped && (
281 <Alert tone="info">
282 <AlertDescription className="mt-0">You skipped this. It's here whenever you want it.</AlertDescription>
283 </Alert>
284 )}
285 <StepBody step={s.key} view={view} onStep={onStep} />
286 </TabsContent>
287 ))}
288 </div>
289 <Footer view={view} here={here} index={index} onStep={onStep} onFinish={onFinish} />
290 </section>
291 </Tabs>
292 );
293}
294
295/**
296 * A phone's rail: where the guide stands, as one segment per step to tap
297 * to go to it (green done, lavender here, dimmed skipped), under the
298 * workspace's name and the step's place.
299 */
300function StepStrip({
301 view,
302 index,
303 onStep,
304 done,
305}: {
306 view: OnboardingView;
307 index: number;
308 onStep: (step: OnboardingStep) => void;
309 done: boolean;
310}) {
311 const { steps } = view;
312 const progress = progressOf(steps);
313 return (
314 <nav aria-label="Steps" className="border-b border-line px-4 pt-4 pr-14 sm:hidden">
315 <p className="flex min-w-0 items-baseline gap-1.5 text-xs text-muted tabular-nums">
316 <span className="truncate font-medium text-fg">{view.name}</span>
317 <span aria-hidden="true">·</span>
318 <span className="shrink-0">
319 {done ? "All set" : `Step ${index + 1} of ${steps.length} · ${progress.settled} done`}
320 </span>
321 </p>
322 <ol className="-mx-0.5 flex">
323 {steps.map((s, i) => (
324 <li key={s.key} className="min-w-0 grow">
325 <button
326 type="button"
327 onClick={() => onStep(s.key)}
328 aria-current={i === index ? "step" : undefined}
329 aria-label={`${s.title}: ${s.done ? "done" : s.skipped ? "skipped" : "to do"}`}
330 className="group flex h-10 w-full items-center px-0.5 outline-none"
331 >
332 <span
333 className={cn(
334 "h-1.5 w-full rounded-full transition-[background-color,height] group-focus-visible:ring-2 group-focus-visible:ring-accent/50",
335 s.done ? "bg-success" : s.skipped ? "bg-line" : "bg-line-strong",
336 i === index && (s.done ? "h-2" : "h-2 bg-accent"),
337 i !== index && s.done && "opacity-60",
338 )}
339 />
340 </button>
341 </li>
342 ))}
343 </ol>
344 </nav>
345 );
346}
347
348/** Skip, back and next; Finish when there is nothing left, or on the last step. */
349function Footer({
350 view,
351 here,
352 index,
353 onStep,
354 onFinish,
355}: {
356 view: OnboardingView;
357 here: OnboardingStepView;
358 index: number;
359 onStep: (step: OnboardingStep) => void;
360 onFinish: () => void;
361}) {
362 const skipper = useFetcher({ key: `onboarding-skip-${view.slug}` });
363 const steps = view.steps;
364 const last = index === steps.length - 1;
365 const done = finished(steps);
366 const next = () => {
367 // Onward to the next step still to do, else simply the next one.
368 const later = steps.slice(index + 1);
369 const target = later.find((s) => !s.done && !s.skipped) ?? later[0];
370 if (target) onStep(target.key);
371 };
372 const canSkip = here.optional && !here.done;
373 return (
374 <div className="flex items-center gap-2 border-t border-line bg-surface px-6 py-3.5 max-sm:sticky max-sm:bottom-0 max-sm:px-4">
375 {canSkip && (
376 <skipper.Form method="post" action={`/${view.slug}/-/onboarding`}>
377 <input type="hidden" name="step" value={here.key} />
378 <SubmitButton
379 fetcher={skipper}
380 variant="ghost"
381 size="sm"
382 name="intent"
383 value={here.skipped ? "unskip" : "skip"}
384 pending={here.skipped ? "Saving…" : "Skipping…"}
385 onClick={() => {
386 if (!here.skipped && !last) setTimeout(next, 0);
387 }}
388 >
389 {here.skipped ? "Don't skip" : "Skip for now"}
390 </SubmitButton>
391 </skipper.Form>
392 )}
393 <div className="ml-auto flex items-center gap-2">
394 {index > 0 && (
395 <Button variant="ghost" size="sm" onClick={() => onStep(steps[index - 1]!.key)}>
396 <ArrowLeft size={14} />
397 Back
398 </Button>
399 )}
400 {/* All done, the steps are still there to look back over. */}
401 {done && !last && (
402 <Button variant="outline" size="sm" onClick={next}>
403 Next
404 <ArrowRight size={14} />
405 </Button>
406 )}
407 {done || last ? (
408 <Button variant="accent" size="sm" onClick={onFinish}>
409 {done ? "Finish" : "Done for now"}
410 </Button>
411 ) : (
412 <Button variant="accent" size="sm" onClick={next}>
413 Next
414 <ArrowRight size={14} />
415 </Button>
416 )}
417 </div>
418 </div>
419 );
420}
421
422function StepBody({ step, view, onStep }: { step: OnboardingStep; view: OnboardingView; onStep: (step: OnboardingStep) => void }) {
423 switch (step) {
424 case "ai":
425 return <AiStep view={view} />;
426 case "ask":
427 return <AskStep view={view} onStep={onStep} />;
428 case "code":
429 return <CodeStep view={view} />;
430 case "harness":
431 return <HarnessStep view={view} />;
432 case "people":
433 return <PeopleStep view={view} />;
434 case "runner":
435 return <RunnerStep view={view} />;
436 }
437}
438
439// --- 1. An AI provider -------------------------------------------------------
440
441/** The frontier labs, offered first as a row of choices; the rest are in "More". */
442const LABS = aiProvidersIn("labs");
443
444function AiStep({ view }: { view: OnboardingView }) {
445 const connections = view.connections ?? [];
446 const [adding, setAdding] = useState(connections.length === 0);
447 return (
448 <div className="space-y-5">
449 {view.connections == null && <ErrorText>Your AI providers couldn't be read just now. Adding one still works.</ErrorText>}
450 {connections.length > 0 && (
451 <Card asChild divided className="overflow-hidden">
452 <ul aria-label="AI providers">
453 {connections.map((connection) => (
454 <li key={connection.id} className="p-3.5">
455 <ProviderRow slug={view.slug} connection={connection} />
456 </li>
457 ))}
458 </ul>
459 </Card>
460 )}
461 {connections.length > 0 ? (
462 <Collapsible open={adding} onOpenChange={setAdding}>
463 <CollapsibleTrigger>
464 <Plus size={14} />
465 Add another provider
466 </CollapsibleTrigger>
467 <CollapsibleContent className="pt-4">
468 <AddProvider slug={view.slug} />
469 </CollapsibleContent>
470 </Collapsible>
471 ) : (
472 <AddProvider slug={view.slug} />
473 )}
474 <p className="text-xs text-faint">
475 Keys are sealed when saved and never shown again. Everything here is on the{" "}
476 <Link to={`/${view.slug}/-/ai`} className="text-muted underline-offset-2 hover:text-fg hover:underline">
477 AI page
478 </Link>{" "}
479 too.
480 </p>
481 </div>
482 );
483}
484
485/** A provider the workspace has: whether its key works, and checking it again. */
486function ProviderRow({ slug, connection }: { slug: string; connection: AiConnection }) {
487 const tester = useFetcher<{ tested?: { id: string; ok: boolean; message: string }; error?: string }>();
488 const tested = tester.data?.tested?.id === connection.id ? tester.data.tested : null;
489 const working = tested ? tested.ok : !connection.lastError;
490 return (
491 <div className="space-y-2">
492 <div className="flex items-center gap-3">
493 <AiProviderMark provider={connection.provider} size={28} />
494 <div className="min-w-0 grow">
495 <p className="truncate text-sm font-medium">{connection.name}</p>
496 <p className="text-xs text-muted sm:truncate">
497 {[AI_PROVIDERS[connection.provider].label, connection.models.length > 0 && `${connection.models.length} models`, connection.secretHint && `key ${connection.secretHint}`]
498 .filter(Boolean)
499 .join(" · ")}
500 </p>
501 </div>
502 <Badge tone={working ? "success" : "warn"} size="md">
503 {working ? "Working" : "Check failed"}
504 </Badge>
505 <tester.Form method="post" action={`/${slug}/-/ai`}>
506 <input type="hidden" name="id" value={connection.id} />
507 <SubmitButton fetcher={tester} variant="outline" size="xs" name="intent" value="test" pending="Testing…">
508 Test
509 </SubmitButton>
510 </tester.Form>
511 </div>
512 {tested ? (
513 <p className={tested.ok ? "text-xs text-success" : "text-xs text-danger"} role="status">
514 {tested.message}
515 </p>
516 ) : (
517 connection.lastError && <p className="text-xs text-warn">{connection.lastError}</p>
518 )}
519 <ErrorText>{tester.data?.error}</ErrorText>
520 </div>
521 );
522}
523
524/** Choosing a provider and giving its key, checked as it is saved (the AI page's own action). */
525function AddProvider({ slug }: { slug: string }) {
526 const adder = useFetcher<{ added?: AiConnection; error?: string; provider?: string }>();
527 const [provider, setProvider] = useState<AiProvider>("anthropic");
528 const added = adder.data?.added ?? null;
529 const more = AI_PROVIDER_GROUPS.filter(({ group }) => group !== "labs");
530 const elsewhere = !LABS.includes(provider);
531 return (
532 <div className="space-y-4">
533 {added && (
534 <Alert tone={added.lastError ? "warn" : "success"} role="status">
535 <AlertTitle>{added.name} is added.</AlertTitle>
536 <AlertDescription>
537 {added.lastError
538 ? `It was saved, but the check failed: ${added.lastError}`
539 : added.models.length > 0
540 ? `The key works, and it offers ${added.models.length} models.`
541 : "The key is saved and works."}
542 </AlertDescription>
543 </Alert>
544 )}
545 <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
546 {LABS.map((key) => (
547 <Button
548 key={key}
549 type="button"
550 variant={provider === key ? "soft" : "outline"}
551 aria-pressed={provider === key}
552 className="h-11 justify-start"
553 onClick={() => setProvider(key)}
554 >
555 <AiProviderMark provider={key} size={20} />
556 {AI_PROVIDERS[key].label}
557 </Button>
558 ))}
559 </div>
560 <Field label="Or a platform, or your own endpoint">
561 <Select value={elsewhere ? provider : ""} onValueChange={(value) => setProvider(value as AiProvider)}>
562 <SelectTrigger aria-label="More providers">
563 <SelectValue placeholder="More providers" />
564 </SelectTrigger>
565 <SelectContent>
566 {more.map(({ group, title }) => (
567 <SelectGroup key={group}>
568 <SelectLabel>{title}</SelectLabel>
569 {aiProvidersIn(group).map((key) => (
570 <SelectItem key={key} value={key} description={AI_PROVIDER_LOOKS[key].blurb}>
571 {AI_PROVIDERS[key].label}
572 </SelectItem>
573 ))}
574 </SelectGroup>
575 ))}
576 </SelectContent>
577 </Select>
578 </Field>
579 <Card tone="plain" className="space-y-4 p-4">
580 <div className="flex items-center gap-3">
581 <AiProviderMark provider={provider} size={28} />
582 <div className="min-w-0">
583 <p className="text-sm font-medium">{AI_PROVIDERS[provider].label}</p>
584 <p className="text-xs text-muted">{AI_PROVIDER_LOOKS[provider].blurb}</p>
585 </div>
586 </div>
587 {/* Keyed by provider so its fields start empty for each. */}
588 <adder.Form key={provider} method="post" action={`/${slug}/-/ai`} className="grid gap-4">
589 <input type="hidden" name="provider" value={provider} />
590 <ModelProviderFields provider={provider} />
591 {adder.data?.provider === provider && <ErrorText>{adder.data.error}</ErrorText>}
592 <div>
593 <SubmitButton fetcher={adder} variant="accent" pending="Checking the key…">
594 <KeyRound size={14} />
595 Add and check the key
596 </SubmitButton>
597 </div>
598 </adder.Form>
599 </Card>
600 </div>
601 );
602}
603
604// --- 2. Ask's model ----------------------------------------------------------
605
606function AskStep({ view, onStep }: { view: OnboardingView; onStep: (step: OnboardingStep) => void }) {
607 const working = (view.connections ?? []).filter((c) => !c.lastError);
608 const setup = view.ask;
609 const saver = useFetcher<{ asked?: boolean; error?: string }>();
610 const current = working.find((c) => c.id === setup?.provider_id) ?? null;
611 const [chosen, setChosen] = useState(current?.id ?? working[0]?.id ?? "");
612 const connection = working.find((c) => c.id === chosen) ?? null;
613 const azure = connection?.provider === "azure_openai";
614 if (working.length === 0) {
615 return (
616 <Card tone="plain" className="space-y-3 p-4">
617 <p className="text-sm text-muted">Ask answers with one of the workspace's AI providers. Add one first.</p>
618 <Button variant="outline" size="sm" onClick={() => onStep("ai")}>
619 <Sparkles size={14} />
620 Add an AI provider
621 </Button>
622 </Card>
623 );
624 }
625 return (
626 <saver.Form method="post" action={`/${view.slug}/-/ai`} className="grid gap-4">
627 <input type="hidden" name="intent" value="ask" />
628 {setup?.problem && (
629 <Alert tone="warn">
630 <AlertDescription className="mt-0">{setup.problem}</AlertDescription>
631 </Alert>
632 )}
633 <div className="grid gap-4 sm:grid-cols-2">
634 <Field label="Provider">
635 <Select name="providerId" value={chosen} onValueChange={setChosen}>
636 <SelectTrigger aria-label="Provider">
637 <SelectValue />
638 </SelectTrigger>
639 <SelectContent>
640 {working.map((c) => (
641 <SelectItem key={c.id} value={c.id} description={AI_PROVIDERS[c.provider].label}>
642 {c.name}
643 </SelectItem>
644 ))}
645 </SelectContent>
646 </Select>
647 </Field>
648 <Field label={azure ? "Deployment" : "Model"}>
649 {connection && autoOffered(connection) ? (
650 <Combobox
651 key={connection.id}
652 name="model"
653 defaultValue={defaultModel(connection, setup)}
654 aria-label="Model"
655 searchPlaceholder="Find a model"
656 emptyText="It offers no model by that name."
657 options={modelOptions(connection)}
658 className="font-mono text-[0.8125rem]"
659 />
660 ) : (
661 <Input
662 key={connection?.id}
663 name="model"
664 required
665 defaultValue={defaultModel(connection, setup)}
666 placeholder={azure ? "The deployment's name" : "The model's name"}
667 className="font-mono text-[0.8125rem]"
668 autoComplete="off"
669 />
670 )}
671 </Field>
672 </div>
673 {saver.data?.asked && !saver.data.error && (
674 <p className="text-sm text-success" role="status">
675 Saved. Ask answers with it from now on.
676 </p>
677 )}
678 <ErrorText>{saver.data?.error}</ErrorText>
679 <div>
680 <SubmitButton fetcher={saver} variant="accent" pending="Saving…">
681 Use for Ask
682 </SubmitButton>
683 </div>
684 </saver.Form>
685 );
686}
687
688// --- 3. Code -----------------------------------------------------------------
689
690/** One way in, as a card with its action. */
691function Choice({ icon, title, children, action }: { icon: ReactNode; title: string; children: ReactNode; action: ReactNode }) {
692 return (
693 <Card tone="plain" className="flex flex-col gap-3 p-4">
694 <div className="flex items-center gap-2 text-sm font-medium">
695 <span className="text-muted">{icon}</span>
696 {title}
697 </div>
698 <p className="grow text-sm text-muted">{children}</p>
699 <div>{action}</div>
700 </Card>
701 );
702}
703
704function CodeStep({ view }: { view: OnboardingView }) {
705 const { projects, github } = view.facts;
706 return (
707 <div className="space-y-4">
708 {(projects ?? 0) > 0 && (
709 <ButtonLink to={`/${view.slug}/-/projects`} variant="outline" size="sm">
710 See its projects
711 <ArrowRight size={14} />
712 </ButtonLink>
713 )}
714 <div className="grid gap-3 sm:grid-cols-2">
715 <Choice
716 icon={<Plus size={15} />}
717 title="Start a new project"
718 action={
719 <ButtonLink to={`/new?workspace=${view.slug}`} variant="accent" size="sm">
720 New project
721 </ButtonLink>
722 }
723 >
724 An empty repository to push to, or one made from a template.
725 </Choice>
726 {github?.configured !== false && (
727 <Choice
728 icon={<GitBranch size={15} />}
729 title="Import from GitHub"
730 action={
731 github?.connected ? (
732 <ButtonLink to="/new/github" variant="outline" size="sm">
733 Choose repositories
734 </ButtonLink>
735 ) : (
736 <ButtonLink to={`/integrations/github/install?workspace=${view.slug}`} variant="outline" size="sm">
737 Connect GitHub
738 </ButtonLink>
739 )
740 }
741 >
742 {github?.connected
743 ? "GitHub is connected. Choose the repositories to bring across, with their history."
744 : "Connect GitHub in one step, then choose the repositories to bring across, with their history."}
745 </Choice>
746 )}
747 </div>
748 </div>
749 );
750}
751
752// --- 4. The harness ----------------------------------------------------------
753
754function Signed({ on, children }: { on: boolean | null; children: ReactNode }) {
755 return (
756 <Badge tone={on ? "success" : "neutral"} size="md">
757 {on ? <Check size={12} /> : <CircleDashed size={12} />}
758 {children}
759 </Badge>
760 );
761}
762
763function Numbered({ n, title, children }: { n: number; title: string; children: ReactNode }) {
764 return (
765 <li className="grid grid-cols-[1.5rem_minmax(0,1fr)] gap-x-3 gap-y-2">
766 <Badge shape="mark" className="tabular-nums">{n}</Badge>
767 <p className="self-center text-sm font-medium">{title}</p>
768 <div className="col-start-2 space-y-2 max-sm:col-span-2 max-sm:col-start-1">{children}</div>
769 </li>
770 );
771}
772
773function HarnessStep({ view }: { view: OnboardingView }) {
774 const { cli, harnessApp } = view.facts;
775 return (
776 <div className="space-y-5">
777 <div className="flex flex-wrap gap-2">
778 <Signed on={cli}>g1t CLI {cli ? "signed in" : "not signed in yet"}</Signed>
779 <Signed on={harnessApp}>Plugin {harnessApp ? "signed in" : "not signed in yet"}</Signed>
780 </div>
781 <ol className="space-y-5">
782 <Numbered n={1} title="Install the g1t CLI and sign in">
783 <Tabs defaultValue="unix">
784 <TabsList aria-label="Your system">
785 <TabsTrigger value="unix">macOS and Linux</TabsTrigger>
786 <TabsTrigger value="windows">Windows</TabsTrigger>
787 </TabsList>
788 <TabsContent value="unix" className="mt-2">
789 <CopyLine prompt text="curl -fsSL https://g1t.sh/install | sh" />
790 </TabsContent>
791 <TabsContent value="windows" className="mt-2">
792 <CopyLine prompt text="irm https://g1t.sh/install.ps1 | iex" />
793 </TabsContent>
794 </Tabs>
795 <CopyLine prompt text="g1t auth login" />
796 </Numbered>
797 <Numbered n={2} title="Add the Claude Code plugin">
798 <CopyLine prompt breakAtSlashes text="claude plugin marketplace add https://g1t.sh/flagon-io/g1t-plugins.git" />
799 <CopyLine prompt text="claude plugin install g1t@g1t" />
800 </Numbered>
801 <Numbered n={3} title="Sign the plugin in">
802 <p className="text-sm text-muted">
803 Start Claude Code again, run <code className="font-mono text-[0.8125rem] text-fg">/mcp</code> and choose{" "}
804 <code className="font-mono text-[0.8125rem] text-fg">plugin:g1t:g1t</code> to sign in through your browser.
805 </p>
806 </Numbered>
807 </ol>
808 <ButtonLink to="https://docs.g1t.sh/guides/claude-code-plugin/" target="_blank" rel="noreferrer" variant="link" size="inline" className="text-sm">
809 The plugin's guide
810 <ExternalLink size={13} />
811 </ButtonLink>
812 </div>
813 );
814}
815
816// --- 5. People ---------------------------------------------------------------
817
818function PeopleStep({ view }: { view: OnboardingView }) {
819 const { people, invitesNeedPlan } = view.facts;
820 if (invitesNeedPlan) {
821 return (
822 <div className="space-y-4">
823 <Alert tone="info">
824 <AlertTitle>Workspace invites come with the plan</AlertTitle>
825 <AlertDescription>
826 Start the plan to invite your team into {view.name}. Until then, you can invite a friend to g1t with one of your own
827 invites.
828 </AlertDescription>
829 </Alert>
830 <div className="flex flex-wrap gap-2">
831 <ButtonLink to={`/${view.slug}/-/billing#plan`} variant="accent" size="sm">
832 See the plan
833 </ButtonLink>
834 <ButtonLink to="/settings/invites" variant="outline" size="sm">
835 Invite a friend to g1t
836 </ButtonLink>
837 </div>
838 </div>
839 );
840 }
841 return (
842 <div className="space-y-4">
843 {people && (people.others > 0 || people.pending > 0) && (
844 <p className="text-sm text-muted">
845 {people.others} {people.others === 1 ? "other member" : "other members"}
846 {people.pending > 0 && `, ${people.pending} ${people.pending === 1 ? "invitation" : "invitations"} waiting for an answer`}.
847 </p>
848 )}
849 <div className="flex flex-wrap gap-2">
850 <ButtonLink to={`/${view.slug}/-/members`} variant="accent" size="sm">
851 <Users size={14} />
852 Invite people
853 </ButtonLink>
854 {people && people.pending > 0 && (
855 <ButtonLink to={`/${view.slug}/-/members/invitations`} variant="outline" size="sm">
856 See invitations
857 </ButtonLink>
858 )}
859 </div>
860 </div>
861 );
862}
863
864// --- 6. A runner -------------------------------------------------------------
865
866function RunnerStep({ view }: { view: OnboardingView }) {
867 return (
868 <div className="flex flex-wrap gap-2">
869 <ButtonLink to="/download" variant="accent" size="sm">
870 <Laptop size={14} />
871 Get the desktop app
872 </ButtonLink>
873 <ButtonLink to={`/${view.slug}/-/runners`} variant="outline" size="sm">
874 {(view.facts.runners ?? 0) > 0 ? "See its runners" : "Other ways to add one"}
875 </ButtonLink>
876 </div>
877 );
878}
879
880// --- Opening it ---------------------------------------------------------------
881
882/**
883 * Getting started on a page that loaded it (Home, the Workspace page):
884 * opens by itself once, when it arrives, for an owner who has not
885 * dismissed it, or when the address asks (`?setup`). Later loads of the
886 * page (after adding a provider, say) refresh what it shows without
887 * opening or closing it.
888 */
889export function OnboardingOnPage({ slug, view: pending, asked }: { slug: string; view: Promise<OnboardingView | null>; asked: boolean }) {
890 const [view, setView] = useState<OnboardingView | null>(null);
891 const [open, setOpen] = useState(false);
892 const decided = useRef(false);
893 useEffect(() => {
894 let live = true;
895 void pending.then((arrived) => {
896 if (!live || !arrived) return;
897 setView(arrived);
898 if (!decided.current) {
899 decided.current = true;
900 if (opensByItself(arrived, asked)) setOpen(true);
901 }
902 });
903 return () => {
904 live = false;
905 };
906 }, [pending, asked]);
907 if (!view) return null;
908 return <OnboardingDialog slug={slug} view={view} open={open} onOpenChange={setOpen} />;
909}
910
911/** The button that opens Getting started from a page that did not load it: Settings → General and the AI page. */
912export function GettingStartedButton({ slug, variant = "outline" }: { slug: string; variant?: "outline" | "soft" }) {
913 const [open, setOpen] = useState(false);
914 return (
915 <>
916 <Hint label="Your AI provider, code, harness, team and runner, step by step">
917 <Button variant={variant} size="sm" onClick={() => setOpen(true)}>
918 <Rocket size={14} />
919 Getting started
920 </Button>
921 </Hint>
922 {open && <OnboardingDialog slug={slug} view={null} open={open} onOpenChange={setOpen} />}
923 </>
924 );
925}