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