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