From 917a7aefc4bb06ac0c0528c3ad3035d49f196b30 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:43:36 -0700 Subject: [PATCH 1/5] Rework onboarding around one default harness Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- desktop/src/features/agents/AGENTS.md | 26 +- .../agents/lib/agentConfigCore.test.mjs | 17 +- .../features/agents/lib/agentConfigCore.ts | 14 +- .../features/agents/ui/AgentConfigFields.tsx | 37 +- .../src/features/agents/ui/bakedEnvHelpers.ts | 22 + .../onboarding/ui/DefaultConfigStep.tsx | 872 +++++++++++++----- .../onboarding/ui/HarnessChoiceCard.tsx | 53 ++ .../onboarding/ui/MachineOnboardingFlow.tsx | 30 +- .../src/features/onboarding/ui/SetupStep.tsx | 663 ++----------- desktop/src/features/onboarding/ui/types.ts | 3 +- desktop/src/testing/e2eBridge.ts | 7 +- .../e2e/onboarding-agent-defaults.spec.ts | 860 +++++++---------- desktop/tests/helpers/bridge.ts | 2 + 13 files changed, 1218 insertions(+), 1388 deletions(-) create mode 100644 desktop/src/features/onboarding/ui/HarnessChoiceCard.tsx diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 35ad4a63af5..54227a2466d 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -34,13 +34,12 @@ with a TypeScript lookup table or an id comparison in a component. descriptor's `currentPersistence` key — never a raw `BUZZ_AGENT_THINKING_EFFORT` literal in UI code. `currentPersistence` is where the value lives *today*; `targetApplication` is how the harness - *should* receive it. They intentionally differ until PR 2.7 migrates - Goose/Claude — do not "fix" one to match the other without doing the - migration work. + *should* receive it. Do not render Goose effort until its native + `GOOSE_THINKING_EFFORT` persistence and live option source are wired. 3. **Field absence has a named reason, not a boolean.** Codex effort is - `ownedByModelId`; Claude effort is `deferredUntilNativeOptionsAvailable`. - New absences get new named reasons in `AgentConfigOmission` / - `render` — never a `showX` prop. + `ownedByModelId`; Goose effort is `pendingNativePersistence`; Claude effort + is `deferredUntilNativeOptionsAvailable`. New absences get new named reasons + in `AgentConfigOmission` / `render` — never a `showX` prop. 4. **The clearing policy is the named types.** `onContextChange: "resetDependentValues"` (user changed harness/provider → dependent values reset everywhere) vs `onCatalogMismatch: "explainOnly" | "onboardingCleanup"` @@ -64,14 +63,17 @@ with a TypeScript lookup table or an id comparison in a component. via `synthesizeEmptyDiscoveryStatus()` and is intentionally **not cached** so that closing → reopening the dialog re-runs discovery after the user installs or signs into the CLI (`isCacheableDiscoveryResponse()`). -7. **Onboarding setup detects readiness; it does not select defaults.** The - setup page derives visible and ready harnesses from the runtime catalog and - only offers install or sign-in actions. The following defaults page is the - sole onboarding surface that chooses and persists `preferred_runtime`, and - its Finish gate consumes the shared renderer's `onValidityChange` signal — - a harness selection alone does not complete onboarding when the harness +7. **Onboarding chooses locally, then sets up one selected harness.** The + chooser page derives visible harnesses from the runtime catalog, renders + simple single-choice cards, and must not install, sign in, configure, or + persist. The selected-harness setup page handles only the chosen harness's + missing work: install, sign-in, or provider/model defaults — and its + Finish gate consumes the shared renderer's `onValidityChange` signal: a + harness selection alone does not complete onboarding when the harness requires provider/model/credential config (e.g. buzz-agent with no provider). Baked build env and runtime-file config satisfy the gate. + Card clicks are local draft state; final completion is the only + onboarding action that persists `preferred_runtime`. `onboarding-agent-defaults.spec.ts` is the acceptance gate for anything touching this flow or the shared renderer. 8. **Omit the Model control only after a confirmed successful empty diff --git a/desktop/src/features/agents/lib/agentConfigCore.test.mjs b/desktop/src/features/agents/lib/agentConfigCore.test.mjs index 62d8a61a6fa..423065443c9 100644 --- a/desktop/src/features/agents/lib/agentConfigCore.test.mjs +++ b/desktop/src/features/agents/lib/agentConfigCore.test.mjs @@ -60,7 +60,7 @@ test("Buzz Agent exposes provider, model, and Buzz-owned effort", () => { }); }); -test("Goose exposes provider, model, and its real effort application key", () => { +test("Goose omits effort until native persistence and options are wired", () => { const model = deriveAgentConfigFieldModel({ config, runtime: runtime("goose", { @@ -72,17 +72,12 @@ test("Goose exposes provider, model, and its real effort application key", () => }); assert.equal( - field(model, "effort").optionSource, - "legacyProviderModelCatalog", + model.fields.some((item) => item.kind === "effort"), + false, ); - assert.deepEqual(field(model, "effort").currentPersistence, { - kind: "envVar", - key: "BUZZ_AGENT_THINKING_EFFORT", - }); - assert.deepEqual(field(model, "effort").targetApplication, { - kind: "envVar", - key: "GOOSE_THINKING_EFFORT", - }); + assert.deepEqual(model.omissions, [ + { kind: "effort", reason: "pendingNativePersistence" }, + ]); }); test("Claude models effort as a deferred native ACP option", () => { diff --git a/desktop/src/features/agents/lib/agentConfigCore.ts b/desktop/src/features/agents/lib/agentConfigCore.ts index 5827aedfa7b..cc99456f413 100644 --- a/desktop/src/features/agents/lib/agentConfigCore.ts +++ b/desktop/src/features/agents/lib/agentConfigCore.ts @@ -73,7 +73,10 @@ export type AgentConfigFieldDescriptor = export type AgentConfigOmission = { kind: "effort"; - reason: "ownedByModelId" | "unsupportedByHarness"; + reason: + | "ownedByModelId" + | "pendingNativePersistence" + | "unsupportedByHarness"; }; export type AgentConfigFieldModel = { @@ -127,13 +130,10 @@ export function deriveAgentConfigFieldModel({ value: config.model, }); - if (runtime?.thinkingEnvVar) { + if (runtime?.id === "buzz-agent" && runtime.thinkingEnvVar) { fields.push({ kind: "effort", - optionSource: - runtime.id === "buzz-agent" - ? "buzzAgentCatalog" - : "legacyProviderModelCatalog", + optionSource: "buzzAgentCatalog", currentPersistence: { kind: "envVar", key: BUZZ_AGENT_THINKING_EFFORT, @@ -142,6 +142,8 @@ export function deriveAgentConfigFieldModel({ render: "control", value: valueFromEnv(config, BUZZ_AGENT_THINKING_EFFORT), }); + } else if (runtime?.id === "goose" && runtime.thinkingEnvVar) { + omissions.push({ kind: "effort", reason: "pendingNativePersistence" }); } else if (runtime?.id === "claude") { fields.push({ kind: "effort", diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 1bd8af89761..5c2fbf88e35 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -1,14 +1,11 @@ /** * Controlled field group for global agent config (provider, model, effort, env vars). * - * Used by AgentDefaultsSettingsCard (settings panel) and AgentDefaultsSection - * (onboarding setup step). The parent manages load/save state; this component is - * purely presentational and calls onConfigChange on every user edit. + * The parent manages load/save state; edits are reported through onConfigChange. */ import * as React from "react"; import { ChevronDown } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; - import type { BakedEnvEntry, RuntimeFileConfigSubset, @@ -27,7 +24,7 @@ import { } from "@/features/agents/lib/agentConfigCore"; import { getBakedProviderInheritLabel, - getGlobalModelFallback, + getRuntimeBakedDefaults, } from "@/features/agents/ui/bakedEnvHelpers"; import { AUTO_PROVIDER_DROPDOWN_VALUE, @@ -271,11 +268,12 @@ export function AgentConfigFields({ effortField?.currentPersistence.kind === "envVar" ? effortField.currentPersistence.key : null; - const bakedProvider = React.useMemo( - () => bakedEnv.find((e) => e.key === "BUZZ_AGENT_PROVIDER")?.value ?? null, - [bakedEnv], - ); const selectedRuntimeId = selectedRuntime?.id ?? ""; + const runtimeBakedDefaults = React.useMemo( + () => getRuntimeBakedDefaults(bakedEnv, selectedRuntime, config.env_vars), + [bakedEnv, config.env_vars, selectedRuntime], + ); + const bakedProvider = runtimeBakedDefaults.provider; const providerFieldVisible = hasRenderableAgentConfigField( fieldModel, "provider", @@ -283,10 +281,7 @@ export function AgentConfigFields({ const effectiveProvider = providerFieldVisible ? config.provider?.trim() || bakedProvider || "" : ""; - const fallbackModel = React.useMemo( - () => getGlobalModelFallback(bakedEnv, effectiveProvider, config.env_vars), - [bakedEnv, config.env_vars, effectiveProvider], - ); + const fallbackModel = runtimeBakedDefaults.model; const modelField = fieldModel.fields.find( (field) => field.kind === "model" && field.render === "control", ); @@ -641,14 +636,20 @@ export function AgentConfigFields({ const effortFieldVisible = showEffortField && effortField !== undefined; const progressiveDefaults = disclosure === "progressive-defaults"; + const onboardingEssential = disclosure === "onboarding-essential"; const fieldClassName = unstyled - ? progressiveDefaults - ? "space-y-1.5" - : "space-y-4" + ? onboardingEssential + ? "space-y-0" + : progressiveDefaults + ? "space-y-1.5" + : "space-y-4" : "space-y-1.5 p-3"; const blockClassName = unstyled ? "" : "p-3"; - const fieldLabelClassName = - unstyled && !progressiveDefaults ? "pl-3" : undefined; + const fieldLabelClassName = onboardingEssential + ? "sr-only" + : unstyled && !progressiveDefaults + ? "pl-3" + : undefined; const providerDropdownOptions = [ ...providerOptions .filter( diff --git a/desktop/src/features/agents/ui/bakedEnvHelpers.ts b/desktop/src/features/agents/ui/bakedEnvHelpers.ts index 40b33282566..b119407365d 100644 --- a/desktop/src/features/agents/ui/bakedEnvHelpers.ts +++ b/desktop/src/features/agents/ui/bakedEnvHelpers.ts @@ -58,6 +58,28 @@ export function getBakedModelInheritLabel(bakedModelId: string): string { return `Inherit build default (${bakedModelId})`; } +/** Resolve baked provider/model values only from this runtime's catalog keys. */ +export function getRuntimeBakedDefaults( + bakedEnv: readonly BakedEnvEntry[], + runtime: + | { id: string; modelEnvVar: string | null; providerEnvVar: string | null } + | undefined, + globalEnv: Readonly>, +): { model: string | null; provider: string | null } { + const provider = runtime?.providerEnvVar + ? resolveInheritedDefault(null, bakedEnv, runtime.providerEnvVar).value + : ""; + const directModel = runtime?.modelEnvVar + ? resolveInheritedDefault(null, bakedEnv, runtime.modelEnvVar).value + : ""; + const model = + directModel || + (runtime?.id === "buzz-agent" + ? getGlobalModelFallback(bakedEnv, provider, globalEnv) + : null); + return { model: model || null, provider: provider || null }; +} + function providerModelEnvKey(provider: string): string | null { switch (provider.trim().toLowerCase()) { case "databricks": diff --git a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx index 50887f08aa7..a5181255283 100644 --- a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx +++ b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx @@ -1,7 +1,11 @@ import * as React from "react"; +import { openUrl } from "@tauri-apps/plugin-opener"; import { + useAcpAuthMethodsQuery, useAcpRuntimesQuery, + useConnectAcpRuntimeMutation, + useInstallAcpRuntimeMutation, useRuntimeFileConfigQuery, } from "@/features/agents/hooks"; import { @@ -9,19 +13,27 @@ import { EMPTY_GLOBAL_CONFIG, } from "@/features/agents/ui/AgentConfigFields"; import { resetConfigForHarnessChange } from "@/features/agents/ui/agentConfigOptions"; -import { AgentDropdownSelect } from "@/features/agents/ui/agentConfigControls"; -import { createSaveCoalescer } from "./saveCoalescer"; +import { getRuntimeBakedDefaults } from "@/features/agents/ui/bakedEnvHelpers"; +import { getGlobalAgentCredentialState } from "@/features/agents/ui/globalAgentCredentialState"; import { getBakedBuildEnv, type BakedEnvEntry } from "@/shared/api/tauri"; import { getGlobalAgentConfig, setGlobalAgentConfig, } from "@/shared/api/tauriGlobalAgentConfig"; import type { + AcpAuthMethod, AcpRuntimeCatalogEntry, GlobalAgentConfig, } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { getInstallErrorMessage } from "@/shared/lib/installError"; import { Button } from "@/shared/ui/button"; import { Spinner } from "@/shared/ui/spinner"; +import { + HARNESS_SETUP_CARD_WIDTH_CLASS, + HarnessChoiceCard, +} from "./HarnessChoiceCard"; +import { RuntimeErrorTooltip } from "./RuntimeErrorTooltip"; import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome"; import { OnboardingFooter } from "./OnboardingFooter"; import { @@ -29,295 +41,695 @@ import { OnboardingSlideTransition, } from "./OnboardingSlideTransition"; import { - getReadyOnboardingRuntimes, getVisibleOnboardingRuntimes, + runtimeIsReadyForOnboarding, } from "./onboardingRuntimeSelection"; +import { getRuntimeDisplayLabel } from "./RuntimeIcon"; import type { DefaultConfigStepActions } from "./types"; type DefaultConfigStepProps = { actions: DefaultConfigStepActions; direction: OnboardingTransitionDirection; - readyRuntimeIds: readonly string[]; + selectedRuntimeId: string | null; +}; + +type InstallResultState = { + error: string | null; + success: boolean; }; -function formatHarnessLabel(runtime: AcpRuntimeCatalogEntry | undefined) { - if (!runtime) return "Select a harness"; - return runtime.id === "buzz-agent" ? "Buzz" : runtime.label; +type SetupPlanKind = + | "loading" + | "catalog-error" + | "missing-runtime" + | "needs-install" + | "needs-sign-in" + | "auth-unknown" + | "auth-invalid" + | "needs-provider-defaults" + | "ready"; + +function runtimeNeedsOnboardingProviderSetup(runtime: AcpRuntimeCatalogEntry) { + return Boolean(runtime.providerEnvVar); +} + +function bakedEnvKeys(bakedEnv: readonly BakedEnvEntry[]) { + return bakedEnv.map((entry) => entry.key); } -function AgentDefaultsSection({ - onPersistenceStateChange, - readyRuntimeIds, +function providerDefaultsAreSatisfied({ + bakedEnv, + config, + runtimeFileConfig, + runtime, }: { - onPersistenceStateChange: (state: { - canComplete: boolean; - flush: () => Promise; - }) => void; - readyRuntimeIds: readonly string[]; + bakedEnv: readonly BakedEnvEntry[]; + config: GlobalAgentConfig; + runtimeFileConfig: + | { + provider: string | null; + model: string | null; + satisfiedEnvKeys: string[]; + } + | null + | undefined; + runtime: AcpRuntimeCatalogEntry; }) { - const runtimesQuery = useAcpRuntimesQuery(); - const [config, setConfig] = - React.useState(EMPTY_GLOBAL_CONFIG); - const [isLoading, setIsLoading] = React.useState(true); - const [isCustomProvider, setIsCustomProvider] = React.useState(false); - const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false); - const [bakedEnv, setBakedEnv] = React.useState([]); - const coalescerRef = React.useRef<{ - enqueue: (value: GlobalAgentConfig) => void; - flush: () => Promise; - cancel: () => void; - } | null>(null); - const [isSaving, setIsSaving] = React.useState(false); - const [configIsValid, setConfigIsValid] = React.useState(false); - - React.useEffect(() => { - let unmounted = false; - - async function loadDefaults() { - const [configResult, bakedEnvResult] = await Promise.allSettled([ - getGlobalAgentConfig(), - getBakedBuildEnv(), - ]); + const runtimeBakedDefaults = getRuntimeBakedDefaults( + bakedEnv, + runtime, + config.env_vars, + ); + const effectiveProvider = + config.provider?.trim() || + runtimeBakedDefaults.provider || + runtimeFileConfig?.provider?.trim() || + ""; + const effectiveModel = + config.model?.trim() || + runtimeFileConfig?.model?.trim() || + runtimeBakedDefaults.model || + ""; + const { credentialsValid } = getGlobalAgentCredentialState({ + bakedEnvKeys: bakedEnvKeys(bakedEnv), + envVars: config.env_vars, + provider: effectiveProvider, + runtimeFileConfig, + runtimeId: runtime.id, + }); - if (unmounted) return; + return ( + effectiveProvider.length > 0 && + effectiveModel.length > 0 && + credentialsValid + ); +} - if (configResult.status === "fulfilled") { - setConfig(configResult.value); - } - if (bakedEnvResult.status === "fulfilled") { - setBakedEnv(bakedEnvResult.value); +function deriveSetupPlan({ + bakedEnv, + config, + isConfigLoading, + runtime, + runtimeFileConfig, +}: { + bakedEnv: readonly BakedEnvEntry[]; + config: GlobalAgentConfig; + isConfigLoading: boolean; + runtime: AcpRuntimeCatalogEntry | undefined; + runtimeFileConfig: + | { + provider: string | null; + model: string | null; + satisfiedEnvKeys: string[]; } - setIsLoading(false); + | null + | undefined; +}): SetupPlanKind { + if (isConfigLoading) return "loading"; + if (!runtime) return "missing-runtime"; + if (runtime.availability !== "available") return "needs-install"; + if (runtime.authStatus.status === "logged_out") return "needs-sign-in"; + if (runtime.authStatus.status === "unknown") return "auth-unknown"; + if (runtime.authStatus.status === "config_invalid") return "auth-invalid"; + + if (runtimeNeedsOnboardingProviderSetup(runtime)) { + if ( + providerDefaultsAreSatisfied({ + bakedEnv, + config, + runtimeFileConfig, + runtime, + }) + ) { + return "ready"; } + return "needs-provider-defaults"; + } - void loadDefaults(); + return runtimeIsReadyForOnboarding(runtime) ? "ready" : "auth-unknown"; +} - // The coalescer serializes autosaves and drains any edit that arrived - // while a previous save was in flight. Cancel on unmount so a slow - // in-flight request never calls setState on an unmounted component. - const coalescer = createSaveCoalescer( - // set_global_agent_config returns a save result (config + restart - // counts); the coalescer round-trips the persisted config only. - async (next) => (await setGlobalAgentConfig(next)).config, - (saving) => { - if (!unmounted) setIsSaving(saving); - }, - (saved) => { - if (!unmounted) setConfig(saved); - }, - ); - coalescerRef.current = coalescer; +function isSupportedOnboardingAuthMethod( + runtime: AcpRuntimeCatalogEntry, + method: AcpAuthMethod, +) { + if (runtime.id !== "codex") return true; + return !/api[-_ ]?key/i.test(`${method.id} ${method.name}`); +} - return () => { - unmounted = true; - coalescer.cancel(); - }; - }, []); - - const effectiveReadyRuntimeIds = React.useMemo( - () => - readyRuntimeIds.length > 0 - ? readyRuntimeIds - : getReadyOnboardingRuntimes(runtimesQuery.data ?? []).map( - (runtime) => runtime.id, - ), - [readyRuntimeIds, runtimesQuery.data], +function isPreferredClaudeAuthMethod(method: AcpAuthMethod) { + const haystack = [ + method.id, + method.name, + method.description ?? "", + method.command.join(" "), + method.args.join(" "), + ] + .join(" ") + .toLowerCase(); + return ( + haystack.includes("claudeai") || + haystack.includes("claude ai") || + haystack.includes("claude.ai") || + haystack.includes("subscription") ); - const readyRuntimeIdSet = React.useMemo( - () => new Set(effectiveReadyRuntimeIds), - [effectiveReadyRuntimeIds], +} + +function getOnboardingAuthMethods( + runtime: AcpRuntimeCatalogEntry, + methods: AcpAuthMethod[], +) { + const supported = methods.filter((method) => + isSupportedOnboardingAuthMethod(runtime, method), ); - // Setup already confirmed readiness. Re-filter only for onboarding - // visibility here; a transient auth recheck must not invalidate that handoff. - const readyRuntimes = React.useMemo( - () => - getVisibleOnboardingRuntimes(runtimesQuery.data ?? []).filter((runtime) => - readyRuntimeIdSet.has(runtime.id), - ), - [readyRuntimeIdSet, runtimesQuery.data], + if (runtime.id === "claude") { + const preferred = + supported.find(isPreferredClaudeAuthMethod) ?? supported[0]; + return preferred ? [preferred] : []; + } + if (runtime.id === "codex") return supported.slice(0, 1); + return supported; +} + +function SelectedRuntimeHeader({ + runtime, +}: { + runtime: AcpRuntimeCatalogEntry | undefined; +}) { + return ( +
+

+ Set up {runtime ? getRuntimeDisplayLabel(runtime) : "your harness"} +

+

+ Buzz will use this harness by default. You can connect and add more + harnesses later in Settings. +

+
); - const selectedRuntime = React.useMemo( - () => - readyRuntimes.find((runtime) => runtime.id === config.preferred_runtime), - [config.preferred_runtime, readyRuntimes], +} + +/** + * Setup-page layout: the selected harness card sits on the left and the + * state-specific description + action live in a left-aligned column on the + * right. The parent page owns the single vanilla fade transition. + */ +function SelectedHarnessLayout({ + children, + runtime, +}: { + children: React.ReactNode; + runtime: AcpRuntimeCatalogEntry; +}) { + return ( +
+
+ +
+
+ {children} +
+
); - const selectedRuntimeId = selectedRuntime?.id ?? ""; - const { data: runtimeFileConfig } = - useRuntimeFileConfigQuery(selectedRuntimeId); - const configSurfaceLoading = isLoading || runtimesQuery.isLoading; - - const configSurfaceError = - runtimesQuery.isError || - (!configSurfaceLoading && - effectiveReadyRuntimeIds.length > 0 && - readyRuntimes.length === 0); - const harnessOptions = React.useMemo( - () => - readyRuntimes.map((runtime) => ({ - label: formatHarnessLabel(runtime), - value: runtime.id, - })), - [readyRuntimes], +} + +function LoadingSetup({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { + return ( + +
+ + Checking setup… +
+
); +} + +function InstallSetup({ + installResult, + isInstalling, + onInstall, + runtime, +}: { + installResult: InstallResultState; + isInstalling: boolean; + onInstall: () => void; + runtime: AcpRuntimeCatalogEntry; +}) { + const installLabel = installResult.error ? "Retry install" : "Install"; - const handleHarnessChange = React.useCallback( - (runtimeId: string) => { - const next = resetConfigForHarnessChange(config, runtimeId); - setIsCustomModelEditing(false); - setIsCustomProvider(false); - setConfig(next); - coalescerRef.current?.enqueue(next); - }, - [config], + return ( + +

+ {getRuntimeDisplayLabel(runtime)} hasn’t been detected on your computer. + You can install it now. +

+ {runtime.canAutoInstall ? ( + + ) : ( + + )} + {installResult.error ? ( + + ) : null} +
); +} + +function SignInSetup({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { + const methodsQuery = useAcpAuthMethodsQuery(runtime.id, { + enabled: + runtime.availability === "available" && + runtime.authStatus.status === "logged_out", + }); + const connectMutation = useConnectAcpRuntimeMutation(); + const runtimesQuery = useAcpRuntimesQuery(); + const [isWaitingForSignIn, setIsWaitingForSignIn] = React.useState(false); + const [didSignInCheckTimeOut, setDidSignInCheckTimeOut] = + React.useState(false); + const isReady = runtimeIsReadyForOnboarding(runtime); React.useEffect(() => { - if (configSurfaceLoading || selectedRuntimeId) return; - if (readyRuntimes.length !== 1) return; - handleHarnessChange(readyRuntimes[0].id); - }, [ - configSurfaceLoading, - handleHarnessChange, - readyRuntimes, - selectedRuntimeId, - ]); - - const flushPersistence = React.useCallback( - () => coalescerRef.current?.flush() ?? Promise.resolve(), - [], - ); + if (!isWaitingForSignIn || !isReady) return; + setIsWaitingForSignIn(false); + setDidSignInCheckTimeOut(false); + }, [isReady, isWaitingForSignIn]); + React.useEffect(() => { - onPersistenceStateChange({ - // configIsValid comes from AgentConfigFields' onValidityChange and - // covers model + provider credentials — a harness selection alone is - // not a working default (e.g. buzz-agent with no provider configured). - canComplete: selectedRuntimeId.length > 0 && configIsValid && !isSaving, - flush: flushPersistence, - }); - }, [ - configIsValid, - flushPersistence, - isSaving, - onPersistenceStateChange, - selectedRuntimeId, - ]); + if (!isWaitingForSignIn) return; + + const interval = window.setInterval(() => { + void runtimesQuery.refetch(); + }, 2_000); + const timeout = window.setTimeout(() => { + setIsWaitingForSignIn(false); + setDidSignInCheckTimeOut(true); + }, 120_000); + + return () => { + window.clearInterval(interval); + window.clearTimeout(timeout); + }; + }, [isWaitingForSignIn, runtimesQuery.refetch]); + + const authMethods = getOnboardingAuthMethods( + runtime, + methodsQuery.data?.methods ?? [], + ); + const authMethod = authMethods[0] ?? null; + const authMethodsUnavailable = + !methodsQuery.isLoading && !methodsQuery.error && authMethod === null; return ( -
- {configSurfaceLoading ? ( -
- - Loading… -
- ) : configSurfaceError ? ( -

- Couldn't load harness settings. Go back and try again. + +

+ Sign in to {getRuntimeDisplayLabel(runtime)} so Buzz can start agents + with this harness. +

+ + {authMethodsUnavailable ? ( +

+ No supported sign-in method is available.

- ) : ( -
-
- - -
- - { - // Always apply optimistically so the UI never reverts mid-save, - // then enqueue the persist — the coalescer serialises multiple - // rapid edits into a single trailing request. - setConfig(next); - coalescerRef.current?.enqueue(next); - }} - onCustomModelEditingChange={setIsCustomModelEditing} - onIsCustomProviderChange={setIsCustomProvider} - onValidityChange={setConfigIsValid} - placeholderClassName="text-foreground/70" - runtimeFileConfig={runtimeFileConfig} - selectClassName="h-12 rounded-2xl border-foreground/15 bg-white px-4 py-2 text-sm shadow-none hover:bg-white/95" - disclosure="onboarding-essential" - unstyled - useCustomSelect - /> -
- )} -
+ ) : null} + {methodsQuery.error instanceof Error ? ( + + ) : null} + {connectMutation.error instanceof Error ? ( + + ) : null} + + ); +} + +function AuthCheckSetup({ + label, + message, + runtime, +}: { + label: string; + message: string; + runtime: AcpRuntimeCatalogEntry; +}) { + const runtimesQuery = useAcpRuntimesQuery(); + + return ( + +

{message}

+ +
+ ); +} + +function ProviderDefaultsSetup({ + bakedEnv, + config, + onConfigChange, + onValidityChange, + runtime, + runtimeFileConfig, +}: { + bakedEnv: BakedEnvEntry[]; + config: GlobalAgentConfig; + onConfigChange: (next: GlobalAgentConfig) => void; + onValidityChange: (valid: boolean) => void; + runtime: AcpRuntimeCatalogEntry; + runtimeFileConfig: + | { + provider: string | null; + model: string | null; + satisfiedEnvKeys: string[]; + } + | null + | undefined; +}) { + const [isCustomProvider, setIsCustomProvider] = React.useState(false); + const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false); + + return ( + +

+ Choose the default provider and model Buzz should use with this harness. +

+
+ +
+
+ ); +} + +function ReadySetup({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { + return ( + +

+ {getRuntimeDisplayLabel(runtime)} is ready. Buzz will use it as your + default harness. +

+
); } -/** - * Machine onboarding page 4 — default model configuration. Presents the - * global agent defaults (provider, model, effort, env vars) centered under - * the mock's "Configure your default model settings" heading. - */ export function DefaultConfigStep({ actions, direction, - readyRuntimeIds, + selectedRuntimeId, }: DefaultConfigStepProps) { - const [persistenceState, setPersistenceState] = React.useState<{ - canComplete: boolean; - flush: () => Promise; - }>({ canComplete: false, flush: () => Promise.resolve() }); + const runtimesQuery = useAcpRuntimesQuery(); + const visibleRuntimes = React.useMemo( + () => getVisibleOnboardingRuntimes(runtimesQuery.data ?? []), + [runtimesQuery.data], + ); + const selectedRuntime = visibleRuntimes.find( + (runtime) => runtime.id === selectedRuntimeId, + ); + const { data: runtimeFileConfig, isLoading: runtimeFileConfigLoading } = + useRuntimeFileConfigQuery(selectedRuntimeId ?? "", { + enabled: Boolean(selectedRuntimeId), + }); + const [config, setConfig] = + React.useState(EMPTY_GLOBAL_CONFIG); + const [bakedEnv, setBakedEnv] = React.useState([]); + const [isConfigLoading, setIsConfigLoading] = React.useState(true); + const [configValid, setConfigValid] = React.useState(false); + const [installResult, setInstallResult] = React.useState({ + error: null, + success: false, + }); + const installMutation = useInstallAcpRuntimeMutation(); const [completionError, setCompletionError] = React.useState( null, ); const [isCompleting, setIsCompleting] = React.useState(false); + React.useEffect(() => { + let unmounted = false; + + async function loadDefaults() { + const [configResult, bakedEnvResult] = await Promise.allSettled([ + getGlobalAgentConfig(), + getBakedBuildEnv(), + ]); + + if (unmounted) return; + + if (configResult.status === "fulfilled") { + const harnessChanged = + selectedRuntimeId !== null && + selectedRuntimeId !== configResult.value.preferred_runtime; + setConfig( + harnessChanged + ? resetConfigForHarnessChange(configResult.value, selectedRuntimeId) + : configResult.value, + ); + } + if (bakedEnvResult.status === "fulfilled") { + setBakedEnv(bakedEnvResult.value); + } + setIsConfigLoading(false); + } + + void loadDefaults(); + + return () => { + unmounted = true; + }; + }, [selectedRuntimeId]); + + const plan: SetupPlanKind = runtimesQuery.isError + ? "catalog-error" + : deriveSetupPlan({ + bakedEnv, + config, + isConfigLoading: + isConfigLoading || + runtimesQuery.isLoading || + runtimeFileConfigLoading, + runtime: selectedRuntime, + runtimeFileConfig, + }); + // Once this page has disclosed provider setup, keep those fields mounted as + // they become valid. Validity gates Finish; it must not replace the form the + // user is actively completing. + const [providerSetupActivated, setProviderSetupActivated] = + React.useState(false); + React.useEffect(() => { + if (plan === "needs-provider-defaults") setProviderSetupActivated(true); + }, [plan]); + const effectivePlan = + providerSetupActivated && + plan === "ready" && + selectedRuntime && + runtimeNeedsOnboardingProviderSetup(selectedRuntime) + ? "needs-provider-defaults" + : plan; + const canFinish = + effectivePlan === "ready" || + (effectivePlan === "needs-provider-defaults" && configValid); + + const handleInstall = React.useCallback(() => { + if (!selectedRuntime) return; + setInstallResult({ error: null, success: false }); + installMutation.mutate(selectedRuntime.id, { + onSuccess: (result) => { + if (result.success) { + setInstallResult({ error: null, success: true }); + void runtimesQuery.refetch(); + return; + } + setInstallResult({ + error: getInstallErrorMessage(result.steps), + success: false, + }); + }, + onError: (error) => { + setInstallResult({ + error: error instanceof Error ? error.message : "Install failed.", + success: false, + }); + }, + }); + }, [installMutation, runtimesQuery, selectedRuntime]); + const handleComplete = React.useCallback(async () => { + if (!selectedRuntimeId) return; setIsCompleting(true); setCompletionError(null); try { - await persistenceState.flush(); + await setGlobalAgentConfig({ + ...config, + preferred_runtime: selectedRuntimeId, + }); actions.complete(); } catch { setCompletionError("Couldn't save your default harness. Try again."); setIsCompleting(false); } - }, [actions, persistenceState]); + }, [actions, config, selectedRuntimeId]); return ( -
-

- Configure your default model settings -

-

- This will be set as your default model configuration across Buzz. You - can always change this in your Settings or give specific agents a - different configuration. -

-
+ -
-
- +
+
+ {effectivePlan === "loading" && selectedRuntime ? ( + + ) : effectivePlan === "loading" ? ( +
+ + Loading… +
+ ) : effectivePlan === "catalog-error" ? ( +
+

+ Couldn't load harness settings. Try again. +

+ +
+ ) : effectivePlan === "missing-runtime" ? ( +

+ Couldn't load this harness. Go back and try again. +

+ ) : selectedRuntime ? ( + effectivePlan === "needs-install" ? ( + + ) : effectivePlan === "needs-sign-in" ? ( + + ) : effectivePlan === "auth-unknown" ? ( + + ) : effectivePlan === "auth-invalid" ? ( + + ) : effectivePlan === "needs-provider-defaults" ? ( + + ) : ( + + ) + ) : null} {completionError ? (

void handleComplete()} type="button" > - Next + Finish

diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index 843206aa706..3c10eb66685 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -1,54 +1,33 @@ import * as React from "react"; -import { openUrl } from "@tauri-apps/plugin-opener"; -import { Check } from "lucide-react"; +import { useReducedMotion } from "motion/react"; -import { - useAcpAuthMethodsQuery, - useAcpRuntimesQuery, - useConnectAcpRuntimeMutation, - useInstallAcpRuntimeMutation, -} from "@/features/agents/hooks"; -import { describeResolvedCommand } from "@/features/agents/ui/agentUi"; -import type { AcpAuthMethod, AcpRuntimeCatalogEntry } from "@/shared/api/types"; -import { getInstallErrorMessage } from "@/shared/lib/installError"; +import { useAcpRuntimesQuery } from "@/features/agents/hooks"; +import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; -import { Card } from "@/shared/ui/card"; import { FlappingBee } from "@/shared/ui/buzz-logo/FlappingBee"; -import { Spinner } from "@/shared/ui/spinner"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; -import { - getReadyOnboardingRuntimes, - getVisibleOnboardingRuntimes, - runtimeIsReadyForOnboarding, -} from "./onboardingRuntimeSelection"; -import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome"; -import { RuntimeErrorTooltip } from "./RuntimeErrorTooltip"; +import { HarnessChoiceCard } from "./HarnessChoiceCard"; +import { getVisibleOnboardingRuntimes } from "./onboardingRuntimeSelection"; import { OnboardingFooter } from "./OnboardingFooter"; -import { getRuntimeDisplayLabel, RuntimeIcon } from "./RuntimeIcon"; import { type OnboardingTransitionDirection, OnboardingSlideTransition, } from "./OnboardingSlideTransition"; import type { SetupStepActions, SetupStepState } from "./types"; +/** How long unselected cards fade before the page advances (ms). */ +const CHOICE_FADE_MS = 200; + type SetupStepProps = { actions: SetupStepActions; direction: OnboardingTransitionDirection; - onReadyRuntimeIdsChange: (runtimeIds: readonly string[]) => void; + selectedRuntimeId: string | null; }; type SetupStepContentProps = SetupStepProps & { state: SetupStepState; }; -type InstallResultState = { - error: string | null; - success: boolean; -}; - -type InstallResultsState = Record; - function useSetupStepState(): SetupStepState { const runtimesQuery = useAcpRuntimesQuery(); const items = runtimesQuery.data ?? []; @@ -65,507 +44,40 @@ function useSetupStepState(): SetupStepState { }; } -function RuntimeReadinessIndicator({ +function RuntimeChoiceCard({ + dimmed, + onChoose, runtime, - ready, + selected, }: { + dimmed: boolean; + onChoose: () => void; runtime: AcpRuntimeCatalogEntry; - ready: boolean; + selected: boolean; }) { - // Checkmark temporarily hidden; flip to true to restore it. - const showReadinessCheckmark = false; - if (!ready || !showReadinessCheckmark) return null; - - return ( - - ); -} - -function RuntimeStatus({ - installError, - isInstalling, - onInstall, - runtime, -}: { - installError: string | null; - isInstalling: boolean; - onInstall: () => void; - runtime: AcpRuntimeCatalogEntry; -}) { - const methodsQuery = useAcpAuthMethodsQuery(runtime.id, { - enabled: - runtime.availability === "available" && - runtime.authStatus.status === "logged_out", - }); - const connectMutation = useConnectAcpRuntimeMutation(); - const runtimesQuery = useAcpRuntimesQuery(); - const [isWaitingForSignIn, setIsWaitingForSignIn] = React.useState(false); - const [didSignInCheckTimeOut, setDidSignInCheckTimeOut] = - React.useState(false); - const isReady = runtimeIsReadyForOnboarding(runtime); - - React.useEffect(() => { - if (!isWaitingForSignIn || !isReady) return; - setIsWaitingForSignIn(false); - setDidSignInCheckTimeOut(false); - }, [isReady, isWaitingForSignIn]); - - React.useEffect(() => { - if (!isWaitingForSignIn) return; - - const interval = window.setInterval(() => { - void runtimesQuery.refetch(); - }, 2_000); - const timeout = window.setTimeout(() => { - setIsWaitingForSignIn(false); - setDidSignInCheckTimeOut(true); - }, 120_000); - - return () => { - window.clearInterval(interval); - window.clearTimeout(timeout); - }; - }, [isWaitingForSignIn, runtimesQuery.refetch]); - const authMethods = getOnboardingAuthMethods( - runtime, - methodsQuery.data?.methods ?? [], - ); - const authMethod = authMethods[0] ?? null; - const shouldSignIn = - runtime.availability === "available" && - runtime.authStatus.status === "logged_out"; - - if (shouldSignIn) { - return ( -
- - {methodsQuery.error instanceof Error ? ( - - ) : null} - {connectMutation.error instanceof Error ? ( - - ) : null} -
- ); - } - - if (isInstalling) { - return ( -
- - INSTALLING -
- ); - } - - if (runtimeIsReadyForOnboarding(runtime)) { - return ( - - - - READY - - - - - - - ); - } - - if ( - runtime.availability === "available" && - runtime.authStatus.status === "unknown" - ) { - return ( - - ); - } - - const installLabel = installError ? "RETRY INSTALL" : "INSTALL"; - if (runtime.canAutoInstall) { - return ( - - ); - } - return ( - - ); -} - -function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { - if ( - runtime.availability === "available" && - runtime.command && - runtime.binaryPath - ) { - const description = describeResolvedCommand( - runtime.command, - runtime.binaryPath, - ); - return ( - <> -

- {description.charAt(0).toUpperCase() + description.slice(1)} -

- {runtime.defaultArgs.length > 0 ? ( -

- Args:{" "} - {runtime.defaultArgs.join(", ")} -

- ) : null} - - ); - } - - if (runtime.availability === "adapter_missing") { - return ( - <> -

- CLI detected; ACP adapter missing. -

-

- {runtime.installHint} -

- - ); - } - - if (runtime.availability === "adapter_outdated") { - return ( - <> -

- ACP adapter detected but outdated — reinstall required. -

-

- This updates the machine-global{" "} - - codex-acp - {" "} - adapter. Older Buzz releases using the legacy adapter contract may - lose community access until{" "} - - @zed-industries/codex-acp@0.16.0 - {" "} - is restored. -

-

- {runtime.installHint} -

- - ); - } - - if (runtime.availability === "cli_missing") { - return ( - <> -

- ACP adapter detected; CLI missing. -

-

- {runtime.installHint} -

- - ); - } - - return ( - <> -

Not installed yet.

-

{runtime.installHint}

- - ); -} - -function runtimeDetailText(runtime: AcpRuntimeCatalogEntry): string { - if ( - runtime.availability === "available" && - runtime.command && - runtime.binaryPath - ) { - const description = describeResolvedCommand( - runtime.command, - runtime.binaryPath, - ); - return description.charAt(0).toUpperCase() + description.slice(1); - } - if (runtime.availability === "adapter_missing") { - return "CLI detected; ACP adapter missing."; - } - if (runtime.availability === "adapter_outdated") { - return "ACP adapter detected but outdated — reinstall required."; - } - if ( - runtime.availability === "cli_missing" || - runtime.availability === "not_installed" - ) { - return "CLI not detected."; - } - return ""; -} - -function isSupportedOnboardingAuthMethod( - runtime: AcpRuntimeCatalogEntry, - method: AcpAuthMethod, -) { - if (runtime.id !== "codex") return true; - return !/api[-_ ]?key/i.test(`${method.id} ${method.name}`); -} - -function isPreferredClaudeAuthMethod(method: AcpAuthMethod) { - const haystack = [ - method.id, - method.name, - method.description ?? "", - method.command.join(" "), - method.args.join(" "), - ] - .join(" ") - .toLowerCase(); - return ( - haystack.includes("claudeai") || - haystack.includes("claude ai") || - haystack.includes("claude.ai") || - haystack.includes("subscription") - ); -} - -function getOnboardingAuthMethods( - runtime: AcpRuntimeCatalogEntry, - methods: AcpAuthMethod[], -) { - const supported = methods.filter((method) => - isSupportedOnboardingAuthMethod(runtime, method), - ); - - if (runtime.id === "claude") { - const preferred = - supported.find(isPreferredClaudeAuthMethod) ?? supported[0]; - return preferred ? [preferred] : []; - } - - if (runtime.id === "codex") { - return supported.slice(0, 1); - } - - return supported; -} - -function RuntimeAuthError({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { - if (runtime.authStatus.status === "config_invalid") { - return ( - - ); - } - if ( - runtime.availability === "available" && - runtime.authStatus.status === "unknown" - ) { - return ( - - ); - } - return null; -} - -function RuntimeCard({ - installResults, - onInstallResultsChange, - runtime, -}: { - installResults: InstallResultsState; - onInstallResultsChange: React.Dispatch< - React.SetStateAction - >; - runtime: AcpRuntimeCatalogEntry; -}) { - // Each card owns its own mutation instance so concurrent installs on - // different cards each track their own isPending state and callbacks - // independently (react-query v5 per-mutate callbacks only fire for the - // latest mutate() call on a shared instance, silently dropping earlier ones). - const installMutation = useInstallAcpRuntimeMutation(); - const installError = installResults[runtime.id]?.error ?? null; - const isInstalling = installMutation.isPending; - const isAvailable = runtime.availability === "available"; - const isReady = runtimeIsReadyForOnboarding(runtime); - - function handleInstall() { - onInstallResultsChange((current) => ({ - ...current, - [runtime.id]: { error: null, success: false }, - })); - - installMutation.mutate(runtime.id, { - onSuccess: (result) => { - onInstallResultsChange((current) => ({ - ...current, - [runtime.id]: result.success - ? { error: null, success: true } - : { - error: getInstallErrorMessage(result.steps), - success: false, - }, - })); - }, - onError: (error) => { - onInstallResultsChange((current) => ({ - ...current, - [runtime.id]: { - error: error instanceof Error ? error.message : "Install failed.", - success: false, - }, - })); - }, - }); - } - - return ( - - - -
-
- -

- {getRuntimeDisplayLabel(runtime)} -

-
- - {!isAvailable && runtimeDetailText(runtime) ? ( -

- {runtimeDetailText(runtime)} -

- ) : null} -
- {installError ? ( - - ) : ( - - )} -
+ { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onChoose(); + }} + role="button" + runtime={runtime} + tabIndex={0} + /> +
); } @@ -588,40 +100,45 @@ function RuntimeProvidersLoadingState() { } function RuntimeProvidersSection({ - installResults, - onInstallResultsChange, + onChooseRuntime, + pendingRuntimeId, runtimeProviders, + selectedRuntimeId, }: { - installResults: InstallResultsState; - onInstallResultsChange: React.Dispatch< - React.SetStateAction - >; + onChooseRuntime: (runtimeId: string) => void; + pendingRuntimeId: string | null; runtimeProviders: SetupStepState["runtimeProviders"]; + selectedRuntimeId: string | null; }) { const { errorMessage, isChecking, items } = runtimeProviders; const orderedItems = getVisibleOnboardingRuntimes(items); + const highlightedRuntimeId = pendingRuntimeId ?? selectedRuntimeId; return (

- Set up your agent harnesses + Choose your default harness

-

- Buzz checks for command-line harnesses on this machine. Install the - CLI or sign in to at least one to continue. +

+ Pick the harness Buzz should use by default. You can always connect + and add more harnesses later in Settings.

-
+
{orderedItems.length > 0 ? ( -
+ // 4-across from md so the grid fits the 800×500 minimum window + // without scrolling under the docked footer (cards shrink below + // their 288px max); single column only on very narrow layouts. +
{orderedItems.map((runtime) => ( - onChooseRuntime(runtime.id)} runtime={runtime} + selected={runtime.id === highlightedRuntimeId} /> ))}
@@ -632,8 +149,8 @@ function RuntimeProvidersSection({ className="max-w-[560px] rounded-2xl bg-white/70 px-6 py-6 text-sm text-muted-foreground" data-testid="onboarding-acp-empty" > - No supported command-line harnesses were detected yet. Install a - supported CLI, then check again. + No supported agent harnesses were detected yet. You can finish + onboarding now and connect a harness later in Settings.

)} @@ -650,55 +167,57 @@ function RuntimeProvidersSection({ function SetupStepContent({ actions, direction, - onReadyRuntimeIdsChange, + selectedRuntimeId, state, }: SetupStepContentProps) { - const { runtimeProviders } = state; - const [installResults, setInstallResults] = - React.useState({}); - const readyRuntimeIds = React.useMemo( - () => - getReadyOnboardingRuntimes(runtimeProviders.items).map( - (runtime) => runtime.id, - ), - [runtimeProviders.items], + const shouldReduceMotion = useReducedMotion() ?? false; + const [pendingRuntimeId, setPendingRuntimeId] = React.useState( + null, + ); + + // Card click: fade the unselected cards out, then advance into the setup + // page's vanilla fade. + const chooseRuntime = React.useCallback( + (runtimeId: string) => { + if (pendingRuntimeId) return; + if (shouldReduceMotion) { + actions.next(runtimeId); + return; + } + setPendingRuntimeId(runtimeId); + }, + [actions, pendingRuntimeId, shouldReduceMotion], ); - const readyRuntimeIdsKey = readyRuntimeIds.join("\0"); - // The key prevents catalog object refreshes from creating an effect loop - // when the detected ready IDs have not changed. - // biome-ignore lint/correctness/useExhaustiveDependencies: keyed by ID content + React.useEffect(() => { - onReadyRuntimeIdsChange(readyRuntimeIds); - }, [onReadyRuntimeIdsChange, readyRuntimeIdsKey]); + if (!pendingRuntimeId) return; + const timeout = window.setTimeout( + () => actions.next(pendingRuntimeId), + CHOICE_FADE_MS, + ); + return () => window.clearTimeout(timeout); + }, [actions, pendingRuntimeId]); return ( - -
+ ) : effectivePlan === "config-error" ? ( +
+

+ Couldn't load your existing default harness. Try again. +

+ +
) : effectivePlan === "catalog-error" ? (

diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index f88d08cc6da..66323cf76db 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -402,6 +402,8 @@ type E2eConfig = { model: string | null; preferred_runtime?: string | null; }; + /** Sequenced `get_global_agent_config` failures. Null succeeds; a string throws. */ + globalAgentConfigErrors?: (string | null)[]; /** File-layer config returned by runtime id. */ runtimeFileConfigs?: Record; /** Baked build env returned by the display and key-name Tauri commands. */ @@ -10954,6 +10956,8 @@ export function maybeInstallE2eTauriMocks() { return config.mock?.runtimeFileConfigs?.[runtimeId] ?? null; } case "get_global_agent_config": { + const readError = activeConfig?.mock?.globalAgentConfigErrors?.shift(); + if (readError) throw new Error(readError); // Return the mutable persisted mock value, seeded from the test config. return ( mockGlobalAgentConfig ?? { diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index 8213b652dac..ccbd05f5a24 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -684,6 +684,97 @@ test("Finish preserves existing values when the selected harness is unchanged", }); }); +test("failed config read blocks Finish, preserves existing config, and retry succeeds", async ({ + page, +}) => { + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + runtime("claude", "available", { status: "logged_in" }), + runtime("codex", "available", { status: "logged_in" }), + ], + globalAgentConfig: { + env_vars: { + ANTHROPIC_API_KEY: "sk-existing", + BUZZ_AGENT_THINKING_EFFORT: "high", + }, + provider: "anthropic", + model: "claude-sonnet-4", + preferred_runtime: "claude", + }, + globalAgentConfigErrors: ["temporary config read failure", null], + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToChooserPage(page); + await page.getByTestId("onboarding-runtime-codex").click(); + + await expect( + page.getByText("Couldn't load your existing default harness. Try again."), + ).toBeVisible(); + await expect(page.getByTestId("onboarding-finish")).toBeDisabled(); + await page.getByTestId("onboarding-finish").click({ force: true }); + await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); + + let saved = await page.evaluate(async () => { + return await ( + window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload: unknown, + ) => Promise<{ + env_vars: Record; + model: string | null; + preferred_runtime: string | null; + provider: string | null; + }>; + } + ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("get_global_agent_config", null); + }); + expect(saved).toMatchObject({ + env_vars: { + ANTHROPIC_API_KEY: "sk-existing", + BUZZ_AGENT_THINKING_EFFORT: "high", + }, + model: "claude-sonnet-4", + preferred_runtime: "claude", + provider: "anthropic", + }); + + await page.getByRole("button", { name: "Try again" }).click(); + await expect(page.getByText(/Codex is ready/i)).toBeVisible(); + await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); + await page.getByTestId("onboarding-finish").click(); + await expect(page.getByText("Join or create a community")).toBeVisible(); + + saved = await page.evaluate(async () => { + return await ( + window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload: unknown, + ) => Promise<{ + env_vars: Record; + model: string | null; + preferred_runtime: string | null; + provider: string | null; + }>; + } + ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("get_global_agent_config", null); + }); + expect(saved).toMatchObject({ + env_vars: { + ANTHROPIC_API_KEY: "sk-existing", + }, + model: null, + preferred_runtime: "codex", + provider: null, + }); + expect(saved?.env_vars).not.toHaveProperty("BUZZ_AGENT_THINKING_EFFORT"); +}); + test("defaults hides model when optional harness has empty discovery", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 562e2f4d22f..972076dd5af 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -422,6 +422,8 @@ type MockBridgeOptions = { model: string | null; preferred_runtime?: string | null; }; + /** Sequenced `get_global_agent_config` failures. Null succeeds; a string throws. */ + globalAgentConfigErrors?: (string | null)[]; /** File-layer config returned by runtime id. */ runtimeFileConfigs?: Record< string, From 8b85a2b315b686b4abf6a43e8a0d1e570004a72c Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:40:59 -0700 Subject: [PATCH 5/5] fix: fail closed on onboarding config load Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- .../onboarding/ui/DefaultConfigStep.tsx | 48 +++++++--- desktop/src/features/onboarding/ui/types.ts | 3 +- desktop/src/testing/e2eBridge.ts | 4 + .../e2e/onboarding-agent-defaults.spec.ts | 96 +++++++++++++++++++ desktop/tests/helpers/bridge.ts | 2 + 5 files changed, 140 insertions(+), 13 deletions(-) diff --git a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx index 1fd2e4bcabd..2c20e187764 100644 --- a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx +++ b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx @@ -77,6 +77,11 @@ function AgentDefaultsSection({ initialDraftRef.current?.isCustomModelEditing ?? false, ); const [bakedEnv, setBakedEnv] = React.useState([]); + const [configLoadError, setConfigLoadError] = React.useState(false); + const [configLoadNonce, retryConfigLoad] = React.useReducer( + (nonce: number) => nonce + 1, + 0, + ); const configRef = React.useRef( initialDraftRef.current?.config ?? EMPTY_GLOBAL_CONFIG, ); @@ -86,7 +91,9 @@ function AgentDefaultsSection({ React.useEffect(() => { let unmounted = false; - async function loadDefaults() { + async function loadDefaults(_retryAttempt: number) { + setIsLoading(true); + setConfigLoadError(false); const [configResult, bakedEnvResult] = await Promise.allSettled([ getGlobalAgentConfig(), getBakedBuildEnv(), @@ -94,25 +101,27 @@ function AgentDefaultsSection({ if (unmounted) return; - if ( - initialDraftRef.current === null && - configResult.status === "fulfilled" - ) { - configRef.current = configResult.value; - setConfig(configResult.value); - } if (bakedEnvResult.status === "fulfilled") { setBakedEnv(bakedEnvResult.value); } + if (configResult.status === "rejected") { + setConfigLoadError(true); + setIsLoading(false); + return; + } + if (initialDraftRef.current === null) { + configRef.current = configResult.value; + setConfig(configResult.value); + } setIsLoading(false); } - void loadDefaults(); + void loadDefaults(configLoadNonce); return () => { unmounted = true; }; - }, []); + }, [configLoadNonce]); const effectiveReadyRuntimeIds = React.useMemo( () => @@ -147,6 +156,7 @@ function AgentDefaultsSection({ const configSurfaceLoading = isLoading || runtimesQuery.isLoading; const configSurfaceError = + configLoadError || runtimesQuery.isError || (!configSurfaceLoading && effectiveReadyRuntimeIds.length > 0 && @@ -212,12 +222,14 @@ function AgentDefaultsSection({ // configIsValid comes from AgentConfigFields' onValidityChange and // covers model + provider credentials — a harness selection alone is // not a working default (e.g. buzz-agent with no provider configured). - canComplete: selectedRuntimeId.length > 0 && configIsValid, + canComplete: + !configLoadError && selectedRuntimeId.length > 0 && configIsValid, commit: commitPersistence, }); }, [ commitPersistence, configIsValid, + configLoadError, onPersistenceStateChange, selectedRuntimeId, ]); @@ -233,6 +245,20 @@ function AgentDefaultsSection({ Loading…

+ ) : configLoadError ? ( +
+

+ Couldn't load your existing default harness. Try again. +

+ +
) : configSurfaceError ? (

Couldn't load harness settings. Go back and try again. diff --git a/desktop/src/features/onboarding/ui/types.ts b/desktop/src/features/onboarding/ui/types.ts index 2658850c75a..5216bfefa4d 100644 --- a/desktop/src/features/onboarding/ui/types.ts +++ b/desktop/src/features/onboarding/ui/types.ts @@ -62,8 +62,7 @@ export type ProfileStepActions = { export type SetupStepActions = { back: () => void; - next: (runtimeId: string) => void; - skip: () => void; + next: (readyRuntimeIds: readonly string[]) => void; navigateToAgentSettings?: () => void; }; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index af17af4f758..85f46f25d5f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -537,6 +537,8 @@ type E2eConfig = { model: string | null; preferred_runtime?: string | null; }; + /** Sequenced `get_global_agent_config` failures. Null succeeds; a string throws. */ + globalAgentConfigErrors?: (string | null)[]; /** Explicit owner-only agent-access capability; independent of baked defaults. */ ownerOnlyAccessBuild?: boolean; /** File-layer config returned by runtime id. */ @@ -13153,6 +13155,8 @@ export function maybeInstallE2eTauriMocks() { return config.mock?.runtimeFileConfigs?.[runtimeId] ?? null; } case "get_global_agent_config": { + const readError = activeConfig?.mock?.globalAgentConfigErrors?.shift(); + if (readError) throw new Error(readError); // Return the mutable persisted mock value, seeded from the test config. return ( mockGlobalAgentConfig ?? { diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index 3a15d21767d..293e7e7362c 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -491,6 +491,102 @@ test("defaults renders only fields supported by the selected harness", async ({ ).toHaveCount(0); }); +test("failed config read blocks Next, preserves existing config, and retry succeeds", async ({ + page, +}) => { + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + runtime("claude", "available", { status: "logged_in" }), + runtime("codex", "available", { status: "logged_in" }), + ], + globalAgentConfig: { + env_vars: { + ANTHROPIC_API_KEY: "sk-existing", + BUZZ_AGENT_THINKING_EFFORT: "high", + }, + provider: "anthropic", + model: "claude-sonnet-4", + preferred_runtime: "claude", + }, + globalAgentConfigErrors: ["temporary config read failure", null], + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page); + await page.getByTestId("onboarding-setup-next").click(); + + await expect( + page.getByText("Couldn't load your existing default harness. Try again."), + ).toBeVisible(); + await expect(page.getByTestId("onboarding-finish")).toBeDisabled(); + await page.getByTestId("onboarding-finish").click({ force: true }); + await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); + + let saved = await page.evaluate(async () => { + return await ( + window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload: unknown, + ) => Promise<{ + env_vars: Record; + model: string | null; + preferred_runtime: string | null; + provider: string | null; + }>; + } + ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("get_global_agent_config", null); + }); + expect(saved).toMatchObject({ + env_vars: { + ANTHROPIC_API_KEY: "sk-existing", + BUZZ_AGENT_THINKING_EFFORT: "high", + }, + model: "claude-sonnet-4", + preferred_runtime: "claude", + provider: "anthropic", + }); + + await page.getByRole("button", { name: "Try again" }).click(); + await expect(page.getByTestId("global-agent-default-harness")).toHaveText( + "Claude Code", + ); + const harness = page.getByTestId("global-agent-default-harness"); + await harness.click(); + await page.getByTestId("global-agent-default-harness-option-codex").click(); + await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); + await page.getByTestId("onboarding-finish").click(); + await expect(page.getByText("Join or create a community")).toBeVisible(); + + saved = await page.evaluate(async () => { + return await ( + window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload: unknown, + ) => Promise<{ + env_vars: Record; + model: string | null; + preferred_runtime: string | null; + provider: string | null; + }>; + } + ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("get_global_agent_config", null); + }); + expect(saved).toMatchObject({ + env_vars: { + ANTHROPIC_API_KEY: "sk-existing", + }, + model: null, + preferred_runtime: "codex", + provider: null, + }); + expect(saved?.env_vars).not.toHaveProperty("BUZZ_AGENT_THINKING_EFFORT"); +}); + test("defaults hides model when optional harness has empty discovery", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index ed94e6b1767..493017582d9 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -497,6 +497,8 @@ type MockBridgeOptions = { model: string | null; preferred_runtime?: string | null; }; + /** Sequenced `get_global_agent_config` failures. Null succeeds; a string throws. */ + globalAgentConfigErrors?: (string | null)[]; ownerOnlyAccessBuild?: boolean; /** File-layer config returned by runtime id. */ runtimeFileConfigs?: Record<