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.

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