Merge branch 'worktree-agent-ac7d6e0b40efe9fe8' into ship-batch
39 files+1153−1123 files not counted0/39 viewed
| 47 | 47 | mod tools; | |
| 48 | 48 | mod why; | |
| 49 | 49 | mod token_policy; | |
| 50 | + | mod onboarding; | |
| 50 | 51 | mod toolkit; | |
| 51 | 52 | ||
| 52 | 53 | use g1t_contracts::identity::{ |
| 1 | + | //! A person's own Getting started in a workspace, over REST: the setup | |
| 2 | + | //! guide its owners see on its Home, at | |
| 3 | + | //! `GET`/`PATCH /workspaces/{workspace}/onboarding`. Identity keeps it | |
| 4 | + | //! (services/identity/src/onboarding.rs): whether the person dismissed it | |
| 5 | + | //! and the steps they skipped. Whether each step is done is read from the | |
| 6 | + | //! workspace itself, so it is not here. People only, each their own; not | |
| 7 | + | //! an action of any MCP tool, since it is about a person at a page. | |
| 8 | + | ||
| 9 | + | use g1t_contracts::identity::{ONBOARDING_STEPS, OnboardingChange}; | |
| 10 | + | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 11 | + | use serde_json::{Value, json}; | |
| 12 | + | use worker::Result; | |
| 13 | + | ||
| 14 | + | use crate::operations::Services; | |
| 15 | + | ||
| 16 | + | /// One operation. | |
| 17 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 18 | + | pub enum OnboardingOp { | |
| 19 | + | GetOnboarding, | |
| 20 | + | UpdateOnboarding, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | impl OnboardingOp { | |
| 24 | + | /// Every one: `Op::ALL` lists each as `Op::Onboarding(…)`, which a test | |
| 25 | + | /// checks against this. | |
| 26 | + | #[cfg(test)] | |
| 27 | + | pub const ALL: [OnboardingOp; 2] = [OnboardingOp::GetOnboarding, OnboardingOp::UpdateOnboarding]; | |
| 28 | + | ||
| 29 | + | pub fn name(self) -> &'static str { | |
| 30 | + | match self { | |
| 31 | + | OnboardingOp::GetOnboarding => "get_onboarding", | |
| 32 | + | OnboardingOp::UpdateOnboarding => "update_onboarding", | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | pub fn title(self) -> &'static str { | |
| 37 | + | match self { | |
| 38 | + | OnboardingOp::GetOnboarding => "Get your Getting started in a workspace", | |
| 39 | + | OnboardingOp::UpdateOnboarding => "Dismiss or skip steps of Getting started", | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | pub fn description(self) -> &'static str { | |
| 44 | + | match self { | |
| 45 | + | OnboardingOp::GetOnboarding => "Your own state of a workspace's Getting started, the setup guide that opens on its Home for its owners: dismissed_at (when you dismissed it, so it no longer opens by itself; null while it does), skipped (the steps you chose to skip, in the guide's order: ai, ask, code, harness, people, runner) and updated_at (null when you never changed it). Whether a step is done is not here: it is the workspace's own state, such as its AI providers, repositories, members and runners. Members only, as people; each person's own.", | |
| 46 | + | OnboardingOp::UpdateOnboarding => "Change your own Getting started in a workspace; fields left out stay as they are. dismissed true dismisses it, so it no longer opens by itself on the workspace's Home (dismissing it again keeps the first time); false makes it open by itself again. skipped replaces the steps you skipped: any of ai, ask, code, harness, people and runner. Opening the guide from Settings or the AI page changes nothing here. Members only, as people; each person's own.", | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /// Whether it changes anything: checked against the scope table in tests. | |
| 51 | + | #[cfg(test)] | |
| 52 | + | pub fn writes(self) -> bool { | |
| 53 | + | self == OnboardingOp::UpdateOnboarding | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | pub fn input(self) -> Value { | |
| 57 | + | let workspace = json!({ "type": "string", "description": "The workspace's name, e.g. \"acme\"." }); | |
| 58 | + | let (properties, required): (Value, &[&str]) = match self { | |
| 59 | + | OnboardingOp::GetOnboarding => (json!({ "workspace": workspace }), &["workspace"]), | |
| 60 | + | OnboardingOp::UpdateOnboarding => ( | |
| 61 | + | json!({ | |
| 62 | + | "workspace": workspace, | |
| 63 | + | "dismissed": { "type": "boolean", "description": "True dismisses it, so it no longer opens by itself; false makes it open by itself again." }, | |
| 64 | + | "skipped": { | |
| 65 | + | "type": "array", | |
| 66 | + | "items": { "type": "string", "enum": ONBOARDING_STEPS }, | |
| 67 | + | "description": "The steps you skip, replacing those you skipped before. An empty list skips none.", | |
| 68 | + | }, | |
| 69 | + | }), | |
| 70 | + | &["workspace"], | |
| 71 | + | ), | |
| 72 | + | }; | |
| 73 | + | json!({ "type": "object", "properties": properties, "required": required }) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /// `dismissed` as sent: a boolean, or a word from a form; null when left out. | |
| 78 | + | fn dismissed(input: &Value) -> std::result::Result<Option<bool>, ()> { | |
| 79 | + | match &input["dismissed"] { | |
| 80 | + | Value::Null => Ok(None), | |
| 81 | + | Value::Bool(on) => Ok(Some(*on)), | |
| 82 | + | Value::String(word) => match word.trim() { | |
| 83 | + | "true" | "1" => Ok(Some(true)), | |
| 84 | + | "false" | "0" => Ok(Some(false)), | |
| 85 | + | _ => Err(()), | |
| 86 | + | }, | |
| 87 | + | _ => Err(()), | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// `skipped` as sent: a list of step names; null when left out. | |
| 92 | + | fn skipped(input: &Value) -> std::result::Result<Option<Vec<String>>, ()> { | |
| 93 | + | match &input["skipped"] { | |
| 94 | + | Value::Null => Ok(None), | |
| 95 | + | Value::Array(items) => items.iter().map(|item| item.as_str().map(str::to_owned).ok_or(())).collect::<std::result::Result<Vec<_>, _>>().map(Some), | |
| 96 | + | _ => Err(()), | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | pub async fn run(op: OnboardingOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 101 | + | let Some(user) = viewer.clone() else { | |
| 102 | + | return Ok(Outcome::fail(FailureCode::Unauthenticated, "This needs a g1t access token.")); | |
| 103 | + | }; | |
| 104 | + | let workspace = input["workspace"].as_str().map(str::trim).unwrap_or_default().to_lowercase(); | |
| 105 | + | if workspace.is_empty() { | |
| 106 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace.")); | |
| 107 | + | } | |
| 108 | + | let identity = &services.identity; | |
| 109 | + | match op { | |
| 110 | + | OnboardingOp::GetOnboarding => g1t_kit::call(identity, "onboarding", &json!({ "user": user, "workspace": workspace })).await, | |
| 111 | + | OnboardingOp::UpdateOnboarding => { | |
| 112 | + | let Ok(dismissed) = dismissed(input) else { | |
| 113 | + | return Ok(Outcome::fail(FailureCode::Invalid, "dismissed is true or false.")); | |
| 114 | + | }; | |
| 115 | + | let Ok(skipped) = skipped(input) else { | |
| 116 | + | return Ok(Outcome::fail(FailureCode::Invalid, "skipped is a list of step names.")); | |
| 117 | + | }; | |
| 118 | + | let change = OnboardingChange { dismissed, skipped }; | |
| 119 | + | g1t_kit::call(identity, "set_onboarding", &json!({ "user": user, "workspace": workspace, "change": change })).await | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | #[cfg(test)] | |
| 125 | + | mod tests { | |
| 126 | + | use super::*; | |
| 127 | + | use g1t_contracts::scopes::{Level, scope_for}; | |
| 128 | + | ||
| 129 | + | #[test] | |
| 130 | + | fn a_change_is_read_from_what_was_sent() { | |
| 131 | + | assert_eq!(dismissed(&json!({})), Ok(None)); | |
| 132 | + | assert_eq!(dismissed(&json!({ "dismissed": true })), Ok(Some(true))); | |
| 133 | + | assert_eq!(dismissed(&json!({ "dismissed": "false" })), Ok(Some(false))); | |
| 134 | + | assert!(dismissed(&json!({ "dismissed": "maybe" })).is_err()); | |
| 135 | + | assert_eq!(skipped(&json!({})), Ok(None)); | |
| 136 | + | assert_eq!(skipped(&json!({ "skipped": ["people"] })), Ok(Some(vec!["people".to_owned()]))); | |
| 137 | + | assert_eq!(skipped(&json!({ "skipped": [] })), Ok(Some(Vec::new()))); | |
| 138 | + | assert!(skipped(&json!({ "skipped": "people" })).is_err()); | |
| 139 | + | assert!(skipped(&json!({ "skipped": [1] })).is_err()); | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | #[test] | |
| 143 | + | fn each_operation_is_described_and_scoped() { | |
| 144 | + | for op in OnboardingOp::ALL { | |
| 145 | + | assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Onboarding(op)), "{}", op.name()); | |
| 146 | + | assert!(crate::tools::REST_ONLY.contains(&crate::operations::Op::Onboarding(op)), "{}", op.name()); | |
| 147 | + | assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name()); | |
| 148 | + | assert!(op.input()["required"].as_array().unwrap().contains(&json!("workspace")), "{}", op.name()); | |
| 149 | + | let level = scope_for(op.name()).unwrap().level(); | |
| 150 | + | assert_eq!(level, if op.writes() { Level::Write } else { Level::Read }, "{}", op.name()); | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | } |
| 19 | 19 | use crate::coordination::CoordinationOp; | |
| 20 | 20 | use crate::protection::ProtectionOp; | |
| 21 | 21 | use crate::token_policy::TokenOp; | |
| 22 | + | use crate::onboarding::OnboardingOp; | |
| 22 | 23 | use crate::operations::Op; | |
| 23 | 24 | use crate::checks::ChecksOp; | |
| 24 | 25 | use crate::rules::RulesOp; | |
| ⋯ | |||
| 69 | 70 | &[Op::GetWorkspace, Op::CreateWorkspace, Op::UpdateWorkspace, Op::DeleteWorkspace, Op::GetWorkspaceActivity], | |
| 70 | 71 | ), | |
| 71 | 72 | ( | |
| 73 | + | "Getting started", | |
| 74 | + | "Each person's own state of a workspace's setup guide, which opens on its Home for its owners until they finish or dismiss it: whether they dismissed it, and the steps they skipped. Whether a step is done is read from the workspace itself. People only, each their own.", | |
| 75 | + | &[Op::Onboarding(OnboardingOp::GetOnboarding), Op::Onboarding(OnboardingOp::UpdateOnboarding)], | |
| 76 | + | ), | |
| 77 | + | ( | |
| 72 | 78 | "Personal access tokens", | |
| 73 | 79 | "A workspace's rules for its members' personal access tokens: whether tokens made for all of a member's workspaces reach it, whether tokens may be made for it alone and wait for an owner's approval, and how long a token may last; the tokens that reach it, approving or denying the ones that wait, and revoking one there. Owners only, as people.", | |
| 74 | 80 | &[ | |
| ⋯ | |||
| 869 | 875 | Op::Telemetry(op) => op.title(), | |
| 870 | 876 | Op::Protection(op) => op.title(), | |
| 871 | 877 | Op::Tokens(op) => op.title(), | |
| 878 | + | Op::Onboarding(op) => op.title(), | |
| 872 | 879 | Op::Artifacts(op) => op.title(), | |
| 873 | 880 | Op::Caches(op) => op.title(), | |
| 874 | 881 | Op::Archives(op) => op.title(), | |
| 41 | 41 | use crate::coordination::CoordinationOp; | |
| 42 | 42 | use crate::protection::ProtectionOp; | |
| 43 | 43 | use crate::token_policy::TokenOp; | |
| 44 | + | use crate::onboarding::OnboardingOp; | |
| 44 | 45 | use crate::rules::RulesOp; | |
| 45 | 46 | use crate::security::SecurityOp; | |
| 46 | 47 | use g1t_contracts::inbox::{Reason, Severity, WATCH_EVENTS, WatchLevel}; | |
| ⋯ | |||
| 342 | 343 | /// A workspace's rules for personal access tokens, its members' | |
| 343 | 344 | /// tokens and approving them: token_policy.rs. | |
| 344 | 345 | Tokens(TokenOp), | |
| 346 | + | /// A person's own Getting started in a workspace: onboarding.rs. | |
| 347 | + | Onboarding(OnboardingOp), | |
| 345 | 348 | /// Workflow run artifacts, and how long they are kept: run_artifacts.rs. | |
| 346 | 349 | Artifacts(ArtifactsOp), | |
| 347 | 350 | /// A repository's actions/cache entries: caches.rs. | |
| ⋯ | |||
| 525 | 528 | "base_url": { "type": "string", "description": "The address of a self-hosted endpoint or an Azure OpenAI resource, over https. An Anthropic-compatible one without /v1; an OpenAI-compatible one up to and including the version." }, | |
| 526 | 529 | "auth_header": { "type": "string", "enum": ["x-api-key", "authorization"], "description": "For an endpoint: send the key as x-api-key, or as authorization: Bearer." }, | |
| 527 | 530 | "model": { "type": "string", "description": "The model the team uses on it; for Azure OpenAI, the deployment." }, | |
| 528 | − | "console_workspace": { "type": "string", "description": "For anthropic: the Console workspace the key belongs to (wrkspc_…), or any Console address in it, so links to the sessions it starts open there. Without it, the account's Default workspace." }, | |
| 531 | + | "console_workspace": { "type": "string", "description": "For anthropic: the Console workspace the key belongs to (wrkspc_…), or any Console address in it, so links to the sessions it starts open there. Without it, the account's Default workspace." }, | |
| 529 | 532 | }, | |
| 530 | 533 | }) | |
| 531 | 534 | } | |
| ⋯ | |||
| 803 | 806 | fn alert_id_schema() -> Value { | |
| 804 | 807 | json!({ | |
| 805 | 808 | "type": "string", | |
| 806 | − | "description": "The alert's id, from list_security_alerts: sec_… for a secret, vul_… for a dependency.", | |
| 809 | + | "description": "The alert's id, from list_security_alerts: sec_… for a secret, vul_… for a dependency.", | |
| 807 | 810 | }) | |
| 808 | 811 | } | |
| 809 | 812 | ||
| 810 | 813 | impl Op { | |
| 811 | − | pub const ALL: [Op; 403] = [ | |
| 814 | + | pub const ALL: [Op; 405] = [ | |
| 812 | 815 | Op::Whoami, | |
| 813 | 816 | Op::GetWorkspace, | |
| 814 | 817 | Op::CreateWorkspace, | |
| ⋯ | |||
| 1166 | 1169 | Op::Tokens(TokenOp::ListTokenRequests), | |
| 1167 | 1170 | Op::Tokens(TokenOp::ReviewTokenRequest), | |
| 1168 | 1171 | Op::Tokens(TokenOp::RevokeMemberToken), | |
| 1172 | + | Op::Onboarding(OnboardingOp::GetOnboarding), | |
| 1173 | + | Op::Onboarding(OnboardingOp::UpdateOnboarding), | |
| 1169 | 1174 | Op::Packages(PackagesOp::ListPackages), | |
| 1170 | 1175 | Op::Packages(PackagesOp::GetPackage), | |
| 1171 | 1176 | Op::Packages(PackagesOp::ListVersions), | |
| ⋯ | |||
| 1436 | 1441 | Op::Telemetry(op) => op.name(), | |
| 1437 | 1442 | Op::Protection(op) => op.name(), | |
| 1438 | 1443 | Op::Tokens(op) => op.name(), | |
| 1444 | + | Op::Onboarding(op) => op.name(), | |
| 1439 | 1445 | Op::Artifacts(op) => op.name(), | |
| 1440 | 1446 | Op::Caches(op) => op.name(), | |
| 1441 | 1447 | Op::Archives(op) => op.name(), | |
| ⋯ | |||
| 1677 | 1683 | } | |
| 1678 | 1684 | Op::ReadSession => "The recorded session of a pull request, oldest entry first.", | |
| 1679 | 1685 | Op::RecordDecision => { | |
| 1680 | − | "Record a decision on a pull request: why the change is made the way it is (proposal), something you say is true of it (claim), what you changed to fix a failure (fix), or a note. It is kept with confidence claimed, attributed to you, with the issue the pull request is for, and published as decision.recorded. evidence is an object of what it rests on, such as {\"commit\": \"…\", \"check\": \"CI\"}; session_id names the session it was made in. g1t records the decisions it saw proved itself: integration_failure and integration_passed when the merge queue combines pull requests (confidence tested), approval when a person approves (approved), and merge." | |
| 1686 | + | "Record a decision on a pull request: why the change is made the way it is (proposal), something you say is true of it (claim), what you changed to fix a failure (fix), or a note. It is kept with confidence claimed, attributed to you, with the issue the pull request is for, and published as decision.recorded. evidence is an object of what it rests on, such as {\"commit\": \"…\", \"check\": \"CI\"}; session_id names the session it was made in. g1t records the decisions it saw proved itself: integration_failure and integration_passed when the merge queue combines pull requests (confidence tested), approval when a person approves (approved), and merge." | |
| 1681 | 1687 | } | |
| 1682 | 1688 | Op::ListDecisions => { | |
| 1683 | 1689 | "The decision records on an issue (with those on its pull requests) or on a pull request, oldest first: each with its kind (proposal, claim, integration_failure, integration_passed, fix, approval, merge or note), its confidence (claimed: someone said so; tested: a check or a combination of pull requests proved it; approved: a person approved it), who recorded it (absent for g1t), its summary and its evidence. Issues and pull requests share numbers, so number names either." | |
| ⋯ | |||
| 2035 | 2041 | Op::Telemetry(op) => op.description(), | |
| 2036 | 2042 | Op::Protection(op) => op.description(), | |
| 2037 | 2043 | Op::Tokens(op) => op.description(), | |
| 2044 | + | Op::Onboarding(op) => op.description(), | |
| 2038 | 2045 | Op::Artifacts(op) => op.description(), | |
| 2039 | 2046 | Op::Caches(op) => op.description(), | |
| 2040 | 2047 | Op::Archives(op) => op.description(), | |
| ⋯ | |||
| 2133 | 2140 | &["email"], | |
| 2134 | 2141 | ), | |
| 2135 | 2142 | Op::RevokeInvite | Op::ResendInvite => object( | |
| 2136 | − | json!({ "id": { "type": "string", "description": "The invite's id, such as inv_01k…" } }), | |
| 2143 | + | json!({ "id": { "type": "string", "description": "The invite's id, such as inv_01k…" } }), | |
| 2137 | 2144 | &["id"], | |
| 2138 | 2145 | ), | |
| 2139 | 2146 | Op::ListWorkspaceInvites => object(json!({ "workspace": workspace_schema() }), &["workspace"]), | |
| ⋯ | |||
| 3030 | 3037 | "repo": repo_schema(), | |
| 3031 | 3038 | "workflow": { "type": "string", "description": "A workflow's id or file name, such as ci.yml." }, | |
| 3032 | 3039 | "branch": { "type": "string" }, | |
| 3033 | − | "event": { "type": "string", "description": "push, pull_request, schedule, workflow_dispatch…" }, | |
| 3040 | + | "event": { "type": "string", "description": "push, pull_request, schedule, workflow_dispatch…" }, | |
| 3034 | 3041 | "pull": { "type": "integer", "description": "A pull request's number." }, | |
| 3035 | 3042 | "sha": { "type": "string", "description": "A commit." }, | |
| 3036 | 3043 | "status": { "type": "string", "description": "Only runs with this status (queued, in_progress, waiting, completed) or conclusion (success, failure, cancelled, skipped, timed_out): failure finds the failed ones." }, | |
| ⋯ | |||
| 3677 | 3684 | Op::Telemetry(op) => op.input(), | |
| 3678 | 3685 | Op::Protection(op) => op.input(), | |
| 3679 | 3686 | Op::Tokens(op) => op.input(), | |
| 3687 | + | Op::Onboarding(op) => op.input(), | |
| 3680 | 3688 | Op::Artifacts(op) => op.input(), | |
| 3681 | 3689 | Op::Caches(op) => op.input(), | |
| 3682 | 3690 | Op::Archives(op) => op.input(), | |
| ⋯ | |||
| 3806 | 3814 | return op.needs_repo(); | |
| 3807 | 3815 | } | |
| 3808 | 3816 | // A workspace's, never one repository's. | |
| 3809 | − | if let Op::Tokens(_) = self { | |
| 3817 | + | if let Op::Tokens(_) | Op::Onboarding(_) = self { | |
| 3810 | 3818 | return false; | |
| 3811 | 3819 | } | |
| 3812 | 3820 | !matches!( | |
| ⋯ | |||
| 6240 | 6248 | Op::Telemetry(op) => crate::telemetry::run(op, services, viewer, input).await, | |
| 6241 | 6249 | Op::Protection(op) => crate::protection::run(op, services, viewer, input).await, | |
| 6242 | 6250 | Op::Tokens(op) => crate::token_policy::run(op, services, viewer, input).await, | |
| 6251 | + | Op::Onboarding(op) => crate::onboarding::run(op, services, viewer, input).await, | |
| 6243 | 6252 | Op::Artifacts(op) => crate::run_artifacts::run(op, services, viewer, input).await, | |
| 6244 | 6253 | Op::Caches(op) => crate::caches::run(op, services, viewer, input).await, | |
| 6245 | 6254 | Op::Archives(op) => crate::archives::run(op, services, viewer, input).await, | |
| 12185 | 12185 | }, | |
| 12186 | 12186 | "notes": "From each token's next request, tokens made for all of a member's workspaces no longer reach this one (tokens made for it alone still do), and neither does a token that lasts longer than 90 days or never expires. They keep working everywhere else." | |
| 12187 | 12187 | }, | |
| 12188 | + | "get_onboarding": { | |
| 12189 | + | "params": { | |
| 12190 | + | "workspace": "flagon-io" | |
| 12191 | + | }, | |
| 12192 | + | "response": { | |
| 12193 | + | "dismissed_at": null, | |
| 12194 | + | "skipped": [], | |
| 12195 | + | "updated_at": null | |
| 12196 | + | }, | |
| 12197 | + | "notes": "Your own: each person has their own Getting started in each workspace they belong to. A person who never dismissed it gets `dismissed_at` null, and the guide opens by itself on the workspace's Home while they own it and steps are left. Whether a step is done is not kept here but read from the workspace: its AI providers (`GET /workspaces/{workspace}/ai/providers`), repositories, members and runners. `404` for anyone who is not a member. See [Getting started](/guides/workspaces/#getting-started)." | |
| 12198 | + | }, | |
| 12199 | + | "update_onboarding": { | |
| 12200 | + | "params": { | |
| 12201 | + | "workspace": "flagon-io" | |
| 12202 | + | }, | |
| 12203 | + | "request": { | |
| 12204 | + | "dismissed": true, | |
| 12205 | + | "skipped": ["people", "runner"] | |
| 12206 | + | }, | |
| 12207 | + | "response": { | |
| 12208 | + | "dismissed_at": "2026-10-14T21:05:00.000Z", | |
| 12209 | + | "skipped": ["people", "runner"], | |
| 12210 | + | "updated_at": "2026-10-14T21:05:00.000Z" | |
| 12211 | + | }, | |
| 12212 | + | "notes": "Dismissing it again keeps the first `dismissed_at`; `\"dismissed\": false` makes it open by itself again. A step that is not one of `ai`, `ask`, `code`, `harness`, `people` and `runner` is refused with `422`. Opening the guide again from the workspace's settings or its AI page changes nothing here." | |
| 12213 | + | }, | |
| 12188 | 12214 | "list_member_tokens": { | |
| 12189 | 12215 | "params": { | |
| 12190 | 12216 | "workspace": "flagon-io" |
| 122 | 122 | Op::Deployments(_) => return as_is, | |
| 123 | 123 | // So does telemetry. | |
| 124 | 124 | Op::Telemetry(_) => return as_is, | |
| 125 | + | // So does Getting started, from identity. | |
| 126 | + | Op::Onboarding(_) => return through::<g1t_contracts::identity::Onboarding>(op, as_is), | |
| 125 | 127 | // Artifacts are shaped by the API itself, in `snake_case`. | |
| 126 | 128 | Op::Artifacts(_) => return as_is, | |
| 127 | 129 | // The cache's entries travel in `snake_case` from the actions service. |
| 14 | 14 | use crate::coordination::CoordinationOp; | |
| 15 | 15 | use crate::protection::ProtectionOp; | |
| 16 | 16 | use crate::token_policy::TokenOp; | |
| 17 | + | use crate::onboarding::OnboardingOp; | |
| 17 | 18 | use crate::operations::Op; | |
| 18 | 19 | use crate::checks::ChecksOp; | |
| 19 | 20 | use crate::rules::RulesOp; | |
| ⋯ | |||
| 62 | 63 | route("POST", "/user/invitations/:id/accept", Op::AcceptInvitation, &[]), | |
| 63 | 64 | route("POST", "/user/invitations/:id/decline", Op::DeclineInvitation, &[]), | |
| 64 | 65 | route("GET", "/workspaces/:workspace/invitations", Op::ListWorkspaceInvites, &[]), | |
| 66 | + | // A person's own Getting started in a workspace. | |
| 67 | + | route("GET", "/workspaces/:workspace/onboarding", Op::Onboarding(OnboardingOp::GetOnboarding), &[]), | |
| 68 | + | route("PATCH", "/workspaces/:workspace/onboarding", Op::Onboarding(OnboardingOp::UpdateOnboarding), &[]), | |
| 65 | 69 | // A workspace's rules for personal access tokens, and its members' tokens. | |
| 66 | 70 | route("GET", "/workspaces/:workspace/personal-access-token-policy", Op::Tokens(TokenOp::GetTokenPolicy), &[]), | |
| 67 | 71 | route("PATCH", "/workspaces/:workspace/personal-access-token-policy", Op::Tokens(TokenOp::SetTokenPolicy), &[]), | |
| 30 | 30 | use crate::coordination::CoordinationOp; | |
| 31 | 31 | use crate::protection::ProtectionOp; | |
| 32 | 32 | use crate::token_policy::TokenOp; | |
| 33 | + | use crate::onboarding::OnboardingOp; | |
| 33 | 34 | use crate::operations::Op; | |
| 34 | 35 | use crate::checks::ChecksOp; | |
| 35 | 36 | use crate::rules::RulesOp; | |
| ⋯ | |||
| 618 | 619 | Op::Telemetry(TelemetryOp::UpdateExport), | |
| 619 | 620 | Op::Telemetry(TelemetryOp::DeleteExport), | |
| 620 | 621 | Op::Telemetry(TelemetryOp::GetUsage), | |
| 622 | + | // A person's own Getting started is about them at a page, not for agents. | |
| 623 | + | Op::Onboarding(OnboardingOp::GetOnboarding), | |
| 624 | + | Op::Onboarding(OnboardingOp::UpdateOnboarding), | |
| 621 | 625 | ]; | |
| 622 | 626 | ||
| 623 | 627 | /// Operations that cannot be undone, or reach beyond g1t's own records: | |
| 24 | 24 | what Ask uses. Members see which providers there are and what Ask answers | |
| 25 | 25 | with. | |
| 26 | 26 | ||
| 27 | + | Adding a provider is the first step of | |
| 28 | + | [Getting started](/guides/workspaces/#getting-started), the setup guide a | |
| 29 | + | new workspace's owners see on Home; owners can add one and choose Ask's | |
| 30 | + | model right there. **Getting started** on this page opens it again. | |
| 31 | + | ||
| 27 | 32 | ## Choose where models come from | |
| 28 | 33 | ||
| 29 | 34 | **Add a provider** lists them in three groups. A workspace can have any |
| 14 | 14 | ||
| 15 | 15 | **Start here** picks the one item to do first. | |
| 16 | 16 | ||
| 17 | + | For a workspace's owners, Home also opens | |
| 18 | + | [Getting started](/guides/workspaces/#getting-started), the setup guide, | |
| 19 | + | until they finish or close it; `?setup=1` opens it again. | |
| 20 | + | ||
| 17 | 21 | Every number on the page comes from what g1t has recorded. Nothing is | |
| 18 | 22 | estimated. When a part of g1t does not answer, the section it feeds says | |
| 19 | 23 | so, and the rest of the page still shows. |
| 1 | 1 | --- | |
| 2 | 2 | title: Workspaces | |
| 3 | − | description: Workspaces, their names and icons, renaming and deleting one, members, owners and the roles that add to a member, inviting people, Workspace mode's settings pages, requiring two-factor authentication, and access tokens that belong to a workspace. | |
| 3 | + | description: Workspaces, their names and icons, renaming and deleting one, members, owners and the roles that add to a member, inviting people, Workspace mode's settings pages, requiring two-factor authentication, access tokens that belong to a workspace, and Getting started, the setup guide for its owners. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | A workspace owns repositories and is the first part of their address: | |
| ⋯ | |||
| 54 | 54 | is already a workspace. The names `g1t` and `g1t-agent` belong to g1t, | |
| 55 | 55 | and nobody can register them. | |
| 56 | 56 | ||
| 57 | + | ## Getting started | |
| 58 | + | ||
| 59 | + | **Getting started** is a setup guide for a workspace's owners. It opens | |
| 60 | + | by itself on the workspace's [Home](/guides/home/) and on its | |
| 61 | + | **Workspace** page, for each owner, until that owner finishes or closes | |
| 62 | + | it. A workspace made before the guide existed opens it once for each of | |
| 63 | + | its owners too. | |
| 64 | + | ||
| 65 | + | It has six steps. Each is ticked from what the workspace has, never from | |
| 66 | + | what you clicked, so a step you did elsewhere is ticked here as well: | |
| 67 | + | ||
| 68 | + | | Step | Done when | | |
| 69 | + | | --- | --- | | |
| 70 | + | | **Add an AI provider** | The workspace has an [AI provider](/guides/ai/) whose key passed its last check. You can add one in the guide: choose a provider, paste its key, and **Add and check the key** saves it and checks it at once, as the AI page does. **Test** checks one again. | | |
| 71 | + | | **Choose Ask's model** | [Ask g1t](/guides/ask/) answers with one of the workspace's providers, and has no problem with it. Choose the provider and a model, or Auto, and **Use for Ask**. | | |
| 72 | + | | **Bring your code** | The workspace has a project, or [GitHub is connected](/guides/github/). **New project** starts one; **Connect GitHub** installs g1t's GitHub App in one step, then you choose the repositories to bring across. | | |
| 73 | + | | **Connect your harness** | You signed in the [g1t CLI](/guides/cli/) (`g1t auth login`), or signed in an app with g1t, as the [Claude Code plugin](/guides/claude-code-plugin/) does from `/mcp`. The guide shows the install commands for macOS and Linux and for Windows, ready to copy. | | |
| 74 | + | | **Invite your team** | The workspace has another member, or an invitation waiting for an answer. Workspace invites come with the plan: on a free workspace the step says so, links to the plan, and offers [inviting a friend to g1t](/guides/authentication/#invite-a-friend) instead. | | |
| 75 | + | | **Add a runner** | The workspace has a [self-hosted runner](/guides/self-hosted-runners/), such as [the desktop app](/guides/desktop/). | | |
| 76 | + | ||
| 77 | + | Every step but the AI provider can be skipped with **Skip for now**; a | |
| 78 | + | skipped step counts as settled, and **Don't skip** takes it back. When | |
| 79 | + | every step is done or skipped, the guide says **All set**, and **Finish** | |
| 80 | + | puts it away. | |
| 81 | + | ||
| 82 | + | Closing the guide puts it away too: it stops opening by itself for you, | |
| 83 | + | in this workspace, on every device. Nothing else changes. Open it again | |
| 84 | + | any time from **Settings → General** (**Getting started**), from the | |
| 85 | + | workspace's **AI** page, or by typing *Getting started* in the command | |
| 86 | + | palette, which opens Home with `?setup=1`. Each owner has their own: | |
| 87 | + | closing it does not close it for anyone else. | |
| 88 | + | ||
| 89 | + | Members who are not owners do not see it. | |
| 90 | + | ||
| 91 | + | From the API, `GET /workspaces/{workspace}/onboarding` says whether you | |
| 92 | + | dismissed it (`dismissed_at`) and which steps you skipped (`skipped`), and | |
| 93 | + | `PATCH /workspaces/{workspace}/onboarding` changes them: `dismissed` | |
| 94 | + | true puts it away, false makes it open by itself again, and `skipped` | |
| 95 | + | replaces the skipped steps (any of `ai`, `ask`, `code`, `harness`, | |
| 96 | + | `people`, `runner`). Whether a step is done is not stored: it is read from | |
| 97 | + | the workspace each time. | |
| 98 | + | ||
| 99 | + | ```sh | |
| 100 | + | curl -X PATCH https://api.g1t.sh/workspaces/acme/onboarding \ | |
| 101 | + | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 102 | + | -H "Content-Type: application/json" \ | |
| 103 | + | -d '{"dismissed": true}' | |
| 104 | + | ``` | |
| 105 | + | ||
| 106 | + | Reading it takes a token with `workspace:read`; changing it, which | |
| 107 | + | changes only your own, takes `account:write`. | |
| 108 | + | ||
| 57 | 109 | ## Display name, slug and icon | |
| 58 | 110 | ||
| 59 | 111 | A workspace has two names: | |
Binary or large file; its contents are not shown.
| 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 | + | } |
| 58 | 58 | pagePath, | |
| 59 | 59 | workspaceSettingsPage, | |
| 60 | 60 | } from "../lib/workspace-nav"; | |
| 61 | + | import { setupPath } from "../lib/onboarding"; | |
| 61 | 62 | import { ChatSidebar } from "./chat/sidebar"; | |
| 62 | 63 | import { FoliosSidebar } from "./folios/sidebar"; | |
| 63 | 64 | import { Rail, ShortcutsDialog, WorkspaceSwitcher, sidebarKeyLabel } from "./rail"; | |
| ⋯ | |||
| 1825 | 1826 | { label: "Members", hint: `${membership.slug} · Access`, to: `/${membership.slug}/-/members`, icon: <Users size={15} /> }, | |
| 1826 | 1827 | ...(membership.role === "owner" | |
| 1827 | 1828 | ? [ | |
| 1829 | + | { label: "Getting started", hint: `${membership.slug} · Set up the workspace`, to: setupPath(membership.slug), icon: <Rocket size={15} /> }, | |
| 1828 | 1830 | { label: "Invitations", hint: `${membership.slug} · Access`, to: `/${membership.slug}/-/members/invitations`, icon: <Mail size={15} /> }, | |
| 1829 | 1831 | { label: "Permissions", hint: `${membership.slug} · Access`, to: `/${membership.slug}/-/permissions`, icon: <ShieldCheck size={15} /> }, | |
| 1830 | 1832 | ] | |
| 5 | 5 | ||
| 6 | 6 | // shadcn/ui's badge, styled with g1t's tokens: a small outlined label whose | |
| 7 | 7 | // tone says how much it matters. `sm` (the default) is the 11px label beside | |
| 8 | − | // a title or in a table; `md` the 12px pill in a list of properties. | |
| 8 | + | // a title or in a table; `md` the 12px pill in a list of properties. Shape | |
| 9 | + | // `mark` is a round 24px cap for a step's number or tick. | |
| 9 | 10 | ||
| 10 | 11 | export const badgeVariants = cva("inline-flex shrink-0 items-center gap-1 border whitespace-nowrap", { | |
| 11 | 12 | variants: { | |
| ⋯ | |||
| 25 | 26 | shape: { | |
| 26 | 27 | pill: "rounded-full", | |
| 27 | 28 | tag: "rounded", | |
| 29 | + | // A step's number or tick: a 24px round mark with one glyph centred. | |
| 30 | + | mark: "size-6 justify-center rounded-full p-0", | |
| 28 | 31 | }, | |
| 29 | 32 | }, | |
| 30 | 33 | defaultVariants: { tone: "neutral", size: "sm", shape: "pill" }, | |
| 34 | 34 | "fixed top-1/2 left-1/2 z-50 grid max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto overscroll-contain rounded-xl border border-line-strong bg-surface p-6 text-fg shadow-2xl shadow-black/60 outline-none data-[state=open]:animate-dialog-in data-[state=closed]:animate-dialog-out motion-reduce:animate-none " + | |
| 35 | 35 | "max-sm:top-auto max-sm:bottom-(--keyboard-inset,0px) max-sm:left-0 max-sm:w-full max-sm:max-w-none max-sm:translate-x-0 max-sm:translate-y-0 max-sm:max-h-[calc(var(--vv-height,100dvh)-env(safe-area-inset-top)-0.5rem)] max-sm:rounded-b-none max-sm:border-x-0 max-sm:border-b-0 max-sm:pb-[calc(1.5rem+env(safe-area-inset-bottom))] max-sm:data-[state=open]:animate-[sheet-in-bottom_0.24s_cubic-bezier(0.16,1,0.3,1)] max-sm:data-[state=closed]:animate-fade-out"; | |
| 36 | 36 | ||
| 37 | + | /** | |
| 38 | + | * `split`: a wide panel with no padding of its own, for a dialog in two | |
| 39 | + | * columns (a rail and a pane, as Getting started is) whose parts scroll on | |
| 40 | + | * their own. Its height is fixed on a computer so moving between steps | |
| 41 | + | * does not make it jump; on a phone it is the sheet, as tall as it needs. | |
| 42 | + | */ | |
| 43 | + | const SIZES = { | |
| 44 | + | default: "", | |
| 45 | + | split: | |
| 46 | + | "max-w-4xl gap-0 p-0 sm:h-[min(42rem,calc(100dvh-4rem))] sm:grid-cols-[16rem_minmax(0,1fr)] sm:grid-rows-[minmax(0,1fr)] sm:overflow-hidden max-sm:grid-cols-[minmax(0,1fr)] max-sm:pb-[env(safe-area-inset-bottom)]", | |
| 47 | + | } as const; | |
| 48 | + | ||
| 37 | 49 | export function DialogContent({ | |
| 38 | 50 | className, | |
| 39 | 51 | children, | |
| 40 | 52 | showClose = true, | |
| 53 | + | size = "default", | |
| 41 | 54 | ...props | |
| 42 | − | }: ComponentProps<typeof Primitive.Content> & { showClose?: boolean }) { | |
| 55 | + | }: ComponentProps<typeof Primitive.Content> & { showClose?: boolean; size?: keyof typeof SIZES }) { | |
| 43 | 56 | return ( | |
| 44 | 57 | <Primitive.Portal> | |
| 45 | 58 | <DialogOverlay /> | |
| 46 | − | <Primitive.Content className={cn(DIALOG_PANEL, className)} {...props}> | |
| 59 | + | <Primitive.Content className={cn(DIALOG_PANEL, SIZES[size], className)} {...props}> | |
| 47 | 60 | {children} | |
| 48 | 61 | {showClose && ( | |
| 49 | 62 | <Primitive.Close asChild> |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Comments
Sign in to comment.