| 1 | /** |
| 2 | * Getting started: the setup guide that opens on a workspace's Home for its |
| 3 | * owners until they finish or dismiss it (components/onboarding.tsx). |
| 4 | * |
| 5 | * Whether each step is done is read from the workspace itself, never from |
| 6 | * clicks: its AI providers and whether one works, what Ask answers with, |
| 7 | * its projects and GitHub, the person's g1t CLI sign-in and the apps they |
| 8 | * signed in to (the Claude Code plugin is one), its members and |
| 9 | * invitations, its runners. Identity keeps only what the person chose |
| 10 | * (`Onboarding`: dismissed, skipped). Pure, so it is tested on its own |
| 11 | * (onboarding.test.ts); lib/onboarding.server.ts reads the facts. |
| 12 | */ |
| 13 | import type { AiConnection, AskSetup, Onboarding, OnboardingStep } from "@g1t/contracts"; |
| 14 | |
| 15 | /** What the guide reads from the workspace, each null when its service did not answer. */ |
| 16 | export type OnboardingFacts = { |
| 17 | /** The workspace's AI providers: how many, and how many passed their last check. */ |
| 18 | providers: { total: number; working: number } | null; |
| 19 | /** Ask answers with a provider, and it has no problem. */ |
| 20 | askReady: boolean | null; |
| 21 | /** The provider and model Ask answers with, as the AI page shows them. */ |
| 22 | askWith: string | null; |
| 23 | /** How many projects the workspace has. */ |
| 24 | projects: number | null; |
| 25 | /** Whether g1t's GitHub App is installed for it, and whether this g1t offers the app at all. */ |
| 26 | github: { configured: boolean; connected: boolean } | null; |
| 27 | /** Whether the person signed the g1t CLI in. */ |
| 28 | cli: boolean | null; |
| 29 | /** Whether the person signed an app in with g1t, as the Claude Code plugin does. */ |
| 30 | harnessApp: boolean | null; |
| 31 | /** Members besides the person, and invitations waiting for an answer. */ |
| 32 | people: { others: number; pending: number } | null; |
| 33 | /** Whether workspace invites need the plan first (a free workspace). */ |
| 34 | invitesNeedPlan: boolean; |
| 35 | /** The workspace's self-hosted runners. */ |
| 36 | runners: number | null; |
| 37 | }; |
| 38 | |
| 39 | /** One step, as the guide shows it. */ |
| 40 | export type OnboardingStepView = { |
| 41 | key: OnboardingStep; |
| 42 | title: string; |
| 43 | /** One line under the title in the rail. */ |
| 44 | summary: string; |
| 45 | /** Done, from the workspace's state. */ |
| 46 | done: boolean; |
| 47 | /** Skipped by the person. */ |
| 48 | skipped: boolean; |
| 49 | /** Whether the person can skip it: everything but the AI provider. */ |
| 50 | optional: boolean; |
| 51 | }; |
| 52 | |
| 53 | /** Everything the dialog needs. */ |
| 54 | export type OnboardingView = { |
| 55 | slug: string; |
| 56 | /** The workspace's name, for the dialog's title. */ |
| 57 | name: string; |
| 58 | state: Onboarding; |
| 59 | /** Its AI providers, as the AI page lists them; null when integrations did not answer. */ |
| 60 | connections: AiConnection[] | null; |
| 61 | /** What Ask answers with. */ |
| 62 | ask: AskSetup | null; |
| 63 | facts: OnboardingFacts; |
| 64 | steps: OnboardingStepView[]; |
| 65 | }; |
| 66 | |
| 67 | const TITLES: Record<OnboardingStep, { title: string; optional: boolean }> = { |
| 68 | ai: { title: "Add an AI provider", optional: false }, |
| 69 | ask: { title: "Choose Ask's model", optional: true }, |
| 70 | code: { title: "Bring your code", optional: true }, |
| 71 | harness: { title: "Connect your harness", optional: true }, |
| 72 | people: { title: "Invite your team", optional: true }, |
| 73 | runner: { title: "Add a runner", optional: true }, |
| 74 | }; |
| 75 | |
| 76 | /** The order the steps are shown in. Mirrors `ONBOARDING_STEPS` in the contracts. */ |
| 77 | export const STEP_ORDER: OnboardingStep[] = ["ai", "ask", "code", "harness", "people", "runner"]; |
| 78 | |
| 79 | function plural(count: number, one: string, many = `${one}s`): string { |
| 80 | return `${count} ${count === 1 ? one : many}`; |
| 81 | } |
| 82 | |
| 83 | /** Whether a step is done, and its line in the rail. */ |
| 84 | function judge(key: OnboardingStep, facts: OnboardingFacts): { done: boolean; summary: string } { |
| 85 | switch (key) { |
| 86 | case "ai": { |
| 87 | const providers = facts.providers; |
| 88 | if (!providers) return { done: false, summary: "Couldn't check your providers" }; |
| 89 | if (providers.working > 0) return { done: true, summary: `${plural(providers.working, "provider")} working` }; |
| 90 | if (providers.total > 0) return { done: false, summary: "Added, but its check failed" }; |
| 91 | return { done: false, summary: "Your own key, for Ask and @g1t" }; |
| 92 | } |
| 93 | case "ask": |
| 94 | if (facts.askReady) return { done: true, summary: facts.askWith ?? "Ask is ready" }; |
| 95 | return { done: false, summary: "Which model answers Ask" }; |
| 96 | case "code": { |
| 97 | const projects = facts.projects ?? 0; |
| 98 | if (projects > 0) return { done: true, summary: plural(projects, "project") }; |
| 99 | if (facts.github?.connected) return { done: true, summary: "GitHub connected" }; |
| 100 | return { done: false, summary: "New, or from GitHub" }; |
| 101 | } |
| 102 | case "harness": |
| 103 | if (facts.cli && facts.harnessApp) return { done: true, summary: "CLI and plugin signed in" }; |
| 104 | if (facts.cli) return { done: true, summary: "g1t CLI signed in" }; |
| 105 | if (facts.harnessApp) return { done: true, summary: "Plugin signed in" }; |
| 106 | return { done: false, summary: "CLI and Claude Code plugin" }; |
| 107 | case "people": { |
| 108 | const people = facts.people; |
| 109 | if (people && people.others > 0) return { done: true, summary: `${plural(people.others, "other member")}` }; |
| 110 | if (people && people.pending > 0) return { done: true, summary: `${plural(people.pending, "invitation")} sent` }; |
| 111 | return { done: false, summary: facts.invitesNeedPlan ? "Invites come with the plan" : "Work together in one place" }; |
| 112 | } |
| 113 | case "runner": |
| 114 | if ((facts.runners ?? 0) > 0) return { done: true, summary: plural(facts.runners ?? 0, "runner") }; |
| 115 | return { done: false, summary: "Run jobs on your own machine" }; |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | /** The steps, in order, each done or not from `facts` and skipped as the person chose. */ |
| 120 | export function stepsFrom(facts: OnboardingFacts, state: Onboarding): OnboardingStepView[] { |
| 121 | return STEP_ORDER.map((key) => { |
| 122 | const { done, summary } = judge(key, facts); |
| 123 | const { title, optional } = TITLES[key]; |
| 124 | return { key, title, summary, done, skipped: !done && optional && state.skipped.includes(key), optional }; |
| 125 | }); |
| 126 | } |
| 127 | |
| 128 | /** How far along: steps done or skipped, of all of them. */ |
| 129 | export function progressOf(steps: OnboardingStepView[]): { settled: number; total: number; percent: number } { |
| 130 | const settled = steps.filter((step) => step.done || step.skipped).length; |
| 131 | return { settled, total: steps.length, percent: Math.round((settled / Math.max(1, steps.length)) * 100) }; |
| 132 | } |
| 133 | |
| 134 | /** Whether every step is done or skipped. */ |
| 135 | export function finished(steps: OnboardingStepView[]): boolean { |
| 136 | return steps.every((step) => step.done || step.skipped); |
| 137 | } |
| 138 | |
| 139 | /** The step to open on: the first one left, else the first. */ |
| 140 | export function firstOpenStep(steps: OnboardingStepView[]): OnboardingStep { |
| 141 | return (steps.find((step) => !step.done && !step.skipped) ?? steps[0])?.key ?? "ai"; |
| 142 | } |
| 143 | |
| 144 | /** |
| 145 | * Whether the guide opens by itself: for an owner who has not dismissed |
| 146 | * it. A workspace that is all set still opens it once, to say so and be |
| 147 | * finished; finishing dismisses it. `?setup` opens it whatever was chosen. |
| 148 | */ |
| 149 | export function opensByItself(view: Pick<OnboardingView, "state"> | null, asked: boolean): boolean { |
| 150 | if (!view) return false; |
| 151 | return asked || view.state.dismissed_at == null; |
| 152 | } |
| 153 | |
| 154 | /** `skipped` with `step` skipped, or not, in the guide's order. */ |
| 155 | export function withSkipped(skipped: OnboardingStep[], step: OnboardingStep, skip: boolean): OnboardingStep[] { |
| 156 | const set = new Set(skipped); |
| 157 | if (skip) set.add(step); |
| 158 | else set.delete(step); |
| 159 | return STEP_ORDER.filter((key) => set.has(key)); |
| 160 | } |
| 161 | |
| 162 | /** Where Getting started is opened from anywhere: the workspace's Home, asked to open it. */ |
| 163 | export function setupPath(slug: string): string { |
| 164 | return `/${slug}/-/home?setup=1`; |
| 165 | } |