diff --git a/desktop/src/features/onboarding/onboardingPreview.ts b/desktop/src/features/onboarding/onboardingPreview.ts index 40d49bee2a..cb27075d7a 100644 --- a/desktop/src/features/onboarding/onboardingPreview.ts +++ b/desktop/src/features/onboarding/onboardingPreview.ts @@ -18,7 +18,9 @@ export type OnboardingPreviewPage = | "identity-key-help" | "backup-password" | "setup" + | "harness-connection-method" | "harness-connection" + | "harness-connection-help" | "harness-connection-detail" | "config" | "community-choice" diff --git a/desktop/src/features/onboarding/ui/HarnessConnectionStep.tsx b/desktop/src/features/onboarding/ui/HarnessConnectionStep.tsx index bc7ed2ed67..40c0bb7923 100644 --- a/desktop/src/features/onboarding/ui/HarnessConnectionStep.tsx +++ b/desktop/src/features/onboarding/ui/HarnessConnectionStep.tsx @@ -1,4 +1,5 @@ -import { ChevronRight } from "lucide-react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { ChevronRight, CreditCard, ExternalLink, KeyRound } from "lucide-react"; import * as React from "react"; import { AgentDropdownSelect } from "@/features/agents/ui/agentConfigControls"; @@ -42,11 +43,13 @@ const CONNECTION_METHOD_OPTIONS = [ function previewRuntime({ available = false, id, + installInstructionsUrl = "https://github.com/block/buzz", label, source = "preset", }: { available?: boolean; id: string; + installInstructionsUrl?: string; label: string; source?: AcpRuntimeCatalogEntry["source"]; }): AcpRuntimeCatalogEntry { @@ -63,7 +66,7 @@ function previewRuntime({ defaultArgs: [], id, installHint: `Install ${label} to connect it to Buzz.`, - installInstructionsUrl: "https://github.com/block/buzz", + installInstructionsUrl, label, loginHint: null, maxRoundsEnvVar: null, @@ -82,23 +85,31 @@ function previewRuntime({ /** Fixed workshop catalog. Availability changes only in React memory. */ export const HARNESS_CONNECTION_OPTIONS: readonly HarnessConnectionOption[] = [ { - methods: ["subscription", "api"], + methods: ["subscription"], runtime: previewRuntime({ available: true, id: "claude", + installInstructionsUrl: "https://code.claude.com/docs/en/getting-started", label: "Claude Code", source: "builtin", }), }, { - methods: ["subscription", "api"], - runtime: previewRuntime({ id: "codex", label: "Codex", source: "builtin" }), + methods: ["subscription"], + runtime: previewRuntime({ + id: "codex", + installInstructionsUrl: "https://developers.openai.com/codex/cli/", + label: "Codex", + source: "builtin", + }), }, { methods: ["api"], runtime: previewRuntime({ available: true, id: "goose", + installInstructionsUrl: + "https://goose-docs.ai/docs/getting-started/installation/", label: "Goose", source: "builtin", }), @@ -114,39 +125,75 @@ export const HARNESS_CONNECTION_OPTIONS: readonly HarnessConnectionOption[] = [ }, { methods: ["subscription"], - runtime: previewRuntime({ id: "cursor", label: "Cursor" }), + runtime: previewRuntime({ + id: "cursor", + installInstructionsUrl: "https://cursor.com/downloads", + label: "Cursor", + }), }, { methods: ["subscription"], - runtime: previewRuntime({ id: "devin", label: "Devin" }), + runtime: previewRuntime({ + id: "devin", + installInstructionsUrl: "https://docs.devin.ai/cli", + label: "Devin", + }), }, { methods: ["api"], - runtime: previewRuntime({ id: "omp", label: "Oh My Pi" }), + runtime: previewRuntime({ + id: "omp", + installInstructionsUrl: "https://omp.sh/", + label: "Oh My Pi", + }), }, { methods: ["api"], - runtime: previewRuntime({ id: "grok", label: "Grok Build" }), + runtime: previewRuntime({ + id: "grok", + installInstructionsUrl: "https://build.x.ai/docs", + label: "Grok Build", + }), }, { methods: ["api"], - runtime: previewRuntime({ id: "opencode", label: "OpenCode" }), + runtime: previewRuntime({ + id: "opencode", + installInstructionsUrl: "https://opencode.ai/docs", + label: "OpenCode", + }), }, { methods: ["api"], - runtime: previewRuntime({ id: "kimi", label: "Kimi Code" }), + runtime: previewRuntime({ + id: "kimi", + installInstructionsUrl: "https://kimi.ai/download", + label: "Kimi Code", + }), }, { methods: ["subscription"], - runtime: previewRuntime({ id: "amp", label: "Amp" }), + runtime: previewRuntime({ + id: "amp", + installInstructionsUrl: "https://github.com/tao12345666333/amp-acp", + label: "Amp", + }), }, { methods: ["api"], - runtime: previewRuntime({ id: "hermes", label: "Hermes Agent" }), + runtime: previewRuntime({ + id: "hermes", + installInstructionsUrl: "https://hermes-agent.nousresearch.com", + label: "Hermes Agent", + }), }, { methods: ["api"], - runtime: previewRuntime({ id: "openclaw", label: "OpenClaw" }), + runtime: previewRuntime({ + id: "openclaw", + installInstructionsUrl: "https://docs.openclaw.ai/start/getting-started", + label: "OpenClaw", + }), }, ]; @@ -157,12 +204,12 @@ export const HARNESS_CONNECTION_OPTIONS: readonly HarnessConnectionOption[] = [ */ export function HarnessConnectionList({ installedIds, - onInstall, + method, onSelect, options, }: { installedIds: ReadonlySet; - onInstall: (option: HarnessConnectionOption) => void; + method?: HarnessConnectionMethod; onSelect: (option: HarnessConnectionOption) => void; options: readonly HarnessConnectionOption[]; }) { @@ -223,27 +270,56 @@ export function HarnessConnectionList({ onScroll={updateScrollEdges} ref={scrollRef} > - {orderedOptions.map((option) => { + {orderedOptions.map((option, index) => { const { runtime } = option; const installed = installedIds.has(runtime.id); + const previousOption = orderedOptions[index - 1]; + const startsNotInstalledSection = + !installed && + (previousOption === undefined || + installedIds.has(previousOption.runtime.id)); const label = getRuntimeDisplayLabel(runtime); + const isEasiestApiOption = + method === "api" && runtime.id === "buzz-agent"; const rowClassName = "group flex min-h-14 w-full items-center gap-3 rounded-xl px-2 py-2 text-left text-sm font-medium text-foreground transition-colors duration-150 ease-out hover:bg-foreground/[0.04] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-foreground/20 motion-reduce:transition-none"; const contents = ( <> - + - {label} + + {label} + {isEasiestApiOption ? ( + <> + + + Easiest + + + ) : null} + ); - if (installed) { - return ( + return ( + + {startsNotInstalledSection ? ( +

+ Not installed +

+ ) : null} - ); - } - - return ( -
- {contents} - -
+
); })} @@ -281,17 +341,25 @@ export function HarnessConnectionList({ export function HarnessConnectionPreview({ installedIds, + method, onBack, - onInstall, + onHelp, onSelect, total, }: { installedIds: ReadonlySet; + method?: HarnessConnectionMethod; onBack: () => void; - onInstall: (option: HarnessConnectionOption) => void; + onHelp?: () => void; onSelect: (option: HarnessConnectionOption) => void; total: number; }) { + const options = method + ? HARNESS_CONNECTION_OPTIONS.filter(({ methods }) => + methods.includes(method), + ) + : HARNESS_CONNECTION_OPTIONS; + return (

- Connect your AI client + {method === "subscription" + ? "Continue with an AI subscription" + : method === "api" + ? "Continue with an AI API key" + : "Connect your AI client"}

-

- Choose which AI client your agents will use. You can change this - anytime. -

+ {!method ? ( +

+ Choose which AI client your agents will use. You can change this + anytime.{" "} + {onHelp ? ( + + ) : null} +

+ ) : null}
@@ -326,6 +411,178 @@ export function HarnessConnectionPreview({ ); } +const CONNECTION_METHOD_CHOICES = [ + { + icon: CreditCard, + label: "I use a subscription", + method: "subscription", + }, + { + icon: KeyRound, + label: "I have an API key", + method: "api", + }, +] as const satisfies ReadonlyArray<{ + icon: React.ComponentType<{ className?: string }>; + label: string; + method: HarnessConnectionMethod; +}>; + +export function HarnessConnectionMethodPreview({ + onBack, + onSelect, + total, +}: { + onBack: () => void; + onSelect: (method: HarnessConnectionMethod) => void; + total: number; +}) { + return ( + + +
+

+ How do you use AI? +

+

+ Choose the option that matches how you access AI. +

+
+ +
+ {CONNECTION_METHOD_CHOICES.map( + ({ icon: MethodIcon, label, method }) => ( + + ), + )} +
+
+
+ ); +} + +function HarnessCompatibilityList({ + method, +}: { + method: HarnessConnectionMethod; +}) { + const options = HARNESS_CONNECTION_OPTIONS.filter(({ methods }) => + methods.includes(method), + ); + + return ( +
    + {options.map(({ runtime }) => ( +
  • + + + + + {getRuntimeDisplayLabel(runtime)} + +
  • + ))} +
+ ); +} + +export function HarnessConnectionHelpPreview({ + onBack, + total, +}: { + onBack: () => void; + total: number; +}) { + return ( + + +
+

+ Choose how to connect +

+

+ Connect an AI client using a subscription or API key. +

+
+ +
+
+

+ I use a subscription +

+

+ Choose this if you sign in to a paid AI product, such as ChatGPT + or Claude. +

+ +
+ +
+

+ I use an API key +

+

+ Choose this if you have an API key from an AI provider or your + team. +

+ +
+
+
+
+ ); +} + const API_PROVIDER_OPTIONS = PERSONA_LLM_PROVIDER_OPTIONS.map((provider) => ({ label: provider.label, value: provider.id, @@ -344,6 +601,15 @@ function defaultApiProvider(runtimeId: string) { return "anthropic"; } +export function runtimeNeedsOnboardingConnection( + method: HarnessConnectionMethod, + runtimeId: string, +) { + return ( + method === "subscription" || runtimeSupportsLlmProviderSelection(runtimeId) + ); +} + function apiKeyLabel(provider: string) { return ( getProviderApiKeyLabel(provider)?.replace("API Key", "API key") ?? "API key" @@ -433,15 +699,21 @@ function ConnectedConfiguration({ } export function HarnessConnectionDetailPreview({ + installed, + lockMethod = false, method, onBack, + onCheckAgain, onContinue, onMethodChange, option, total, }: { + installed: boolean; + lockMethod?: boolean; method: HarnessConnectionMethod; onBack: () => void; + onCheckAgain: () => void; onContinue: () => void; onMethodChange: (method: HarnessConnectionMethod) => void; option: HarnessConnectionOption; @@ -449,7 +721,7 @@ export function HarnessConnectionDetailPreview({ }) { const cardLayout = useOnboardingPreviewCardLayout(); const label = getRuntimeDisplayLabel(option.runtime); - const hasMethodChoice = option.methods.length > 1; + const hasMethodChoice = option.methods.length > 1 && !lockMethod; const [provider, setProvider] = React.useState(() => defaultApiProvider(option.runtime.id), ); @@ -496,6 +768,70 @@ export function HarnessConnectionDetailPreview({ if (canConnect) setConnected(true); } + if (!installed) { + return ( + + +
+ + + +

+ Set up {label} +

+
+

+ Follow the setup guide to install {label}. When you’re done, come + back and check again. +

+ +
+
+

+ CLI not detected +

+ +
+
+ + + + +
+
+ ); + } + return ( ("subscription"); const [harnessConnectionInOnboarding, setHarnessConnectionInOnboarding] = React.useState(true); + const [ + chooseHarnessConnectionMethodFirst, + setChooseHarnessConnectionMethodFirst, + ] = React.useState(false); const [setupBackPage, setSetupBackPage] = React.useState("landing"); const [communityRoute, setCommunityRoute] = @@ -442,9 +449,20 @@ export function OnboardingPreviewApp() { const continueFromAccount = React.useCallback( (backPage: OnboardingPreviewPage) => { setSetupBackPage(backPage); - setPage(journey.afterAccount); + setPage( + variant === "v3" && + harnessConnectionInOnboarding && + chooseHarnessConnectionMethodFirst + ? "harness-connection-method" + : journey.afterAccount, + ); }, - [journey.afterAccount], + [ + chooseHarnessConnectionMethodFirst, + harnessConnectionInOnboarding, + journey.afterAccount, + variant, + ], ); let content: React.ReactNode; @@ -574,32 +592,81 @@ export function OnboardingPreviewApp() { onNext={() => setPage("config")} /> ); + } else if (page === "harness-connection-method") { + content = ( + setPage(setupBackPage)} + onSelect={(method) => { + setHarnessConnectionMethod(method); + setPage("harness-connection"); + }} + total={journey.totalSteps} + /> + ); } else if (page === "harness-connection") { content = ( setPage(setupBackPage)} - onInstall={(option) => { - setInstalledHarnessIds((current) => - new Set(current).add(option.runtime.id), - ); - setSelectedHarnessId(option.runtime.id); - setHarnessConnectionMethod(option.methods[0] ?? "api"); - setPage("harness-connection-detail"); - }} + method={ + chooseHarnessConnectionMethodFirst + ? harnessConnectionMethod + : undefined + } + onBack={() => + setPage( + chooseHarnessConnectionMethodFirst + ? "harness-connection-method" + : setupBackPage, + ) + } + onHelp={ + chooseHarnessConnectionMethodFirst + ? undefined + : () => setPage("harness-connection-help") + } onSelect={(option) => { setSelectedHarnessId(option.runtime.id); - setHarnessConnectionMethod(option.methods[0] ?? "api"); - setPage("harness-connection-detail"); + const nextMethod = chooseHarnessConnectionMethodFirst + ? harnessConnectionMethod + : (option.methods[0] ?? "api"); + setHarnessConnectionMethod(nextMethod); + setPage( + installedHarnessIds.has(option.runtime.id) && + !runtimeNeedsOnboardingConnection(nextMethod, option.runtime.id) + ? "community-choice" + : "harness-connection-detail", + ); }} total={journey.totalSteps} /> ); + } else if (page === "harness-connection-help") { + content = ( + setPage("harness-connection")} + total={journey.totalSteps} + /> + ); } else if (page === "harness-connection-detail") { content = ( setPage("harness-connection")} + onCheckAgain={() => { + setInstalledHarnessIds((current) => + new Set(current).add(selectedHarness.runtime.id), + ); + if ( + !runtimeNeedsOnboardingConnection( + harnessConnectionMethod, + selectedHarness.runtime.id, + ) + ) { + setPage("community-choice"); + } + }} onContinue={() => setPage("community-choice")} onMethodChange={setHarnessConnectionMethod} option={selectedHarness} @@ -700,7 +767,12 @@ export function OnboardingPreviewApp() { return ( <> { + setChooseHarnessConnectionMethodFirst(enabled); + restart(); + }} onHarnessConnectionInOnboardingChange={(included) => { setHarnessConnectionInOnboarding(included); restart(); diff --git a/desktop/src/features/onboarding/ui/OnboardingPreviewControls.tsx b/desktop/src/features/onboarding/ui/OnboardingPreviewControls.tsx index ae15ebc9f9..7c64283bed 100644 --- a/desktop/src/features/onboarding/ui/OnboardingPreviewControls.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingPreviewControls.tsx @@ -11,13 +11,17 @@ const PREVIEW_VARIANT_OPTIONS = [ ] as const; export function OnboardingPreviewControls({ + chooseHarnessConnectionMethodFirst, harnessConnectionInOnboarding, + onChooseHarnessConnectionMethodFirstChange, onHarnessConnectionInOnboardingChange, onRestart, onVariantChange, variant, }: { + chooseHarnessConnectionMethodFirst: boolean; harnessConnectionInOnboarding: boolean; + onChooseHarnessConnectionMethodFirstChange: (enabled: boolean) => void; onHarnessConnectionInOnboardingChange: (included: boolean) => void; onRestart: () => void; onVariantChange: (variant: OnboardingPreviewVariant) => void; @@ -62,20 +66,38 @@ export function OnboardingPreviewControls({ value={variant} /> {variant === "v3" ? ( - +
+ + {harnessConnectionInOnboarding ? ( + + ) : null} +
) : null} ); diff --git a/desktop/src/features/onboarding/ui/OnboardingPreviewJourney.tsx b/desktop/src/features/onboarding/ui/OnboardingPreviewJourney.tsx index c7d0d7dce9..02cdf4cd65 100644 --- a/desktop/src/features/onboarding/ui/OnboardingPreviewJourney.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingPreviewJourney.tsx @@ -347,18 +347,18 @@ export function CommunityChoicePreview({ return cardLayout ? (