| 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 | */ |
| 15 | import { |
| 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"; |
| 31 | import { type ReactNode, useEffect, useRef, useState } from "react"; |
| 32 | import { Link, useFetcher } from "react-router"; |
| 33 | |
| 34 | import { type AiConnection, type AiProvider, type OnboardingStep, AI_PROVIDERS } from "@g1t/contracts"; |
| 35 | |
| 36 | import { AI_PROVIDER_GROUPS, AI_PROVIDER_LOOKS, aiProvidersIn } from "../lib/ai-providers"; |
| 37 | import { autoOffered, defaultModel, modelOptions } from "../lib/ask-model"; |
| 38 | import { cn } from "../lib/cn"; |
| 39 | import { |
| 40 | type OnboardingStepView, |
| 41 | type OnboardingView, |
| 42 | STEP_ORDER, |
| 43 | finished, |
| 44 | firstOpenStep, |
| 45 | opensByItself, |
| 46 | progressOf, |
| 47 | } from "../lib/onboarding"; |
| 48 | import { AgentSetup } from "./agent-setup"; |
| 49 | import { AiProviderMark } from "./connectors"; |
| 50 | import { ModelProviderFields } from "./model-providers"; |
| 51 | import { ButtonLink, CopyLine, ErrorText, Field, Input, SubmitButton } from "./ui"; |
| 52 | import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; |
| 53 | import { Avatar } from "./ui/avatar"; |
| 54 | import { Badge } from "./ui/badge"; |
| 55 | import { Button } from "./ui/button"; |
| 56 | import { Card } from "./ui/card"; |
| 57 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; |
| 58 | import { Combobox } from "./ui/combobox"; |
| 59 | import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./ui/dialog"; |
| 60 | import { Hint } from "./ui/hint"; |
| 61 | import { Progress } from "./ui/progress"; |
| 62 | import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "./ui/select"; |
| 63 | import { Skeleton } from "./ui/skeleton"; |
| 64 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; |
| 65 | |
| 66 | /** Each step's icon and what it is for, under its title. */ |
| 67 | const 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. */ |
| 95 | function 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`. */ |
| 118 | function 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 | */ |
| 134 | export 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 | |
| 186 | function 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 | |
| 206 | function 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 | */ |
| 301 | function 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. */ |
| 350 | function 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 | |
| 423 | function 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". */ |
| 443 | const LABS = aiProvidersIn("labs"); |
| 444 | |
| 445 | function 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. */ |
| 487 | function 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). */ |
| 526 | function 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 | |
| 607 | function 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. */ |
| 692 | function 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 | |
| 705 | function 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 | |
| 755 | function 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 | |
| 764 | function 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 | |
| 774 | function 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 | |
| 818 | function 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 | |
| 866 | function 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 | */ |
| 889 | export 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. */ |
| 912 | export 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 | } |