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.

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