Rework onboarding around one default harness

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-07-29 09:07:17 -07:00
parent 9752b816a9
commit 917a7aefc4
13 changed files with 1324 additions and 1494 deletions
+14 -12
View File
@@ -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
@@ -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", () => {
@@ -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",
@@ -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(
@@ -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<Record<string, string>>,
): { 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":
@@ -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,46 +41,517 @@ 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;
};
function formatHarnessLabel(runtime: AcpRuntimeCatalogEntry | undefined) {
if (!runtime) return "Select a harness";
return runtime.id === "buzz-agent" ? "Buzz" : runtime.label;
type InstallResultState = {
error: string | null;
success: boolean;
};
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 AgentDefaultsSection({
onPersistenceStateChange,
readyRuntimeIds,
function bakedEnvKeys(bakedEnv: readonly BakedEnvEntry[]) {
return bakedEnv.map((entry) => entry.key);
}
function providerDefaultsAreSatisfied({
bakedEnv,
config,
runtimeFileConfig,
runtime,
}: {
onPersistenceStateChange: (state: {
canComplete: boolean;
flush: () => Promise<void>;
}) => void;
readyRuntimeIds: readonly string[];
bakedEnv: readonly BakedEnvEntry[];
config: GlobalAgentConfig;
runtimeFileConfig:
| {
provider: string | null;
model: string | null;
satisfiedEnvKeys: string[];
}
| null
| undefined;
runtime: AcpRuntimeCatalogEntry;
}) {
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,
});
return (
effectiveProvider.length > 0 &&
effectiveModel.length > 0 &&
credentialsValid
);
}
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[];
}
| 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";
}
return runtimeIsReadyForOnboarding(runtime) ? "ready" : "auth-unknown";
}
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 SelectedRuntimeHeader({
runtime,
}: {
runtime: AcpRuntimeCatalogEntry | undefined;
}) {
return (
<div className="w-full max-w-[540px] text-center">
<h1 className="text-title font-normal text-foreground">
Set up {runtime ? getRuntimeDisplayLabel(runtime) : "your harness"}
</h1>
<p className="mx-auto mt-3 max-w-[440px] text-sm leading-5 text-foreground/80">
Buzz will use this harness by default. You can connect and add more
harnesses later in Settings.
</p>
</div>
);
}
/**
* 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 (
<div className="flex w-full flex-col items-center gap-8 sm:flex-row sm:items-center sm:justify-center sm:gap-14">
<div
className={cn("shrink-0", HARNESS_SETUP_CARD_WIDTH_CLASS)}
data-testid="onboarding-selected-runtime-card"
>
<HarnessChoiceCard runtime={runtime} />
</div>
<div className="flex w-full max-w-2xs flex-col items-start gap-4 text-left [@media(max-height:560px)]:gap-2">
{children}
</div>
</div>
);
}
function LoadingSetup({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
return (
<SelectedHarnessLayout runtime={runtime}>
<div
aria-live="polite"
className="flex items-center gap-2 text-sm text-muted-foreground"
role="status"
>
<Spinner className="h-4 w-4 border-2" />
Checking setup…
</div>
</SelectedHarnessLayout>
);
}
function InstallSetup({
installResult,
isInstalling,
onInstall,
runtime,
}: {
installResult: InstallResultState;
isInstalling: boolean;
onInstall: () => void;
runtime: AcpRuntimeCatalogEntry;
}) {
const installLabel = installResult.error ? "Retry install" : "Install";
return (
<SelectedHarnessLayout runtime={runtime}>
<p className="text-sm leading-6 text-foreground/80">
{getRuntimeDisplayLabel(runtime)} hasn’t been detected on your computer.
You can install it now.
</p>
{runtime.canAutoInstall ? (
<Button
className={`${ONBOARDING_PRIMARY_CTA_CLASS} text-sm`}
data-testid={`onboarding-runtime-install-${runtime.id}`}
disabled={isInstalling}
onClick={onInstall}
type="button"
>
{isInstalling ? (
<span className="inline-flex items-center gap-2">
<Spinner className="h-4 w-4 border-2 text-foreground" />
Installing…
</span>
) : (
installLabel
)}
</Button>
) : (
<Button
className={`${ONBOARDING_PRIMARY_CTA_CLASS} text-sm`}
onClick={() => void openUrl(runtime.installInstructionsUrl)}
type="button"
>
View install instructions
</Button>
)}
{installResult.error ? (
<RuntimeErrorTooltip
className="flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap text-xs leading-4 text-destructive"
detail={installResult.error}
label="Installation failed"
showIcon
testId={`onboarding-runtime-error-${runtime.id}`}
/>
) : null}
</SelectedHarnessLayout>
);
}
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 (!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 authMethodsUnavailable =
!methodsQuery.isLoading && !methodsQuery.error && authMethod === null;
return (
<SelectedHarnessLayout runtime={runtime}>
<p className="text-sm leading-6 text-foreground/80">
Sign in to {getRuntimeDisplayLabel(runtime)} so Buzz can start agents
with this harness.
</p>
<Button
aria-label={`Sign in to ${runtime.label}`}
className={`${ONBOARDING_PRIMARY_CTA_CLASS} text-sm`}
data-testid={`onboarding-runtime-instructions-${runtime.id}`}
disabled={connectMutation.isPending || authMethodsUnavailable}
onClick={() => {
if (didSignInCheckTimeOut) {
setDidSignInCheckTimeOut(false);
setIsWaitingForSignIn(true);
void runtimesQuery.refetch();
return;
}
if (!authMethod) {
void methodsQuery.refetch();
return;
}
connectMutation.mutate(
{ methodId: authMethod.id, runtimeId: runtime.id },
{ onSuccess: () => setIsWaitingForSignIn(true) },
);
}}
type="button"
>
{isWaitingForSignIn
? "Checking…"
: didSignInCheckTimeOut
? "Check again"
: "Sign in"}
</Button>
{authMethodsUnavailable ? (
<p className="text-xs leading-4 text-destructive" role="status">
No supported sign-in method is available.
</p>
) : null}
{methodsQuery.error instanceof Error ? (
<RuntimeErrorTooltip
className="text-xs leading-4 text-destructive"
detail="Couldn’t load sign-in options."
label="Sign-in unavailable"
/>
) : null}
{connectMutation.error instanceof Error ? (
<RuntimeErrorTooltip
className="text-xs leading-4 text-destructive"
detail="Couldn’t start sign-in. Try again."
label="Sign-in failed"
/>
) : null}
</SelectedHarnessLayout>
);
}
function AuthCheckSetup({
label,
message,
runtime,
}: {
label: string;
message: string;
runtime: AcpRuntimeCatalogEntry;
}) {
const runtimesQuery = useAcpRuntimesQuery();
const [config, setConfig] =
React.useState<GlobalAgentConfig>(EMPTY_GLOBAL_CONFIG);
const [isLoading, setIsLoading] = React.useState(true);
return (
<SelectedHarnessLayout runtime={runtime}>
<p className="text-sm leading-6 text-foreground/80">{message}</p>
<Button
className={`${ONBOARDING_PRIMARY_CTA_CLASS} text-sm`}
disabled={runtimesQuery.isFetching}
onClick={() => void runtimesQuery.refetch()}
type="button"
>
{runtimesQuery.isFetching ? "Checking…" : label}
</Button>
</SelectedHarnessLayout>
);
}
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 (
<SelectedHarnessLayout runtime={runtime}>
<p className="text-sm leading-6 text-foreground/80">
Choose the default provider and model Buzz should use with this harness.
</p>
<div className="w-full">
<AgentConfigFields
bakedEnv={bakedEnv}
selectedRuntime={runtime}
config={config}
isCustomModelEditing={isCustomModelEditing}
isCustomProvider={isCustomProvider}
onConfigChange={onConfigChange}
onCustomModelEditingChange={setIsCustomModelEditing}
onIsCustomProviderChange={setIsCustomProvider}
onValidityChange={onValidityChange}
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
/>
</div>
</SelectedHarnessLayout>
);
}
function ReadySetup({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
return (
<SelectedHarnessLayout runtime={runtime}>
<p className="text-sm leading-6 text-foreground/80">
{getRuntimeDisplayLabel(runtime)} is ready. Buzz will use it as your
default harness.
</p>
</SelectedHarnessLayout>
);
}
export function DefaultConfigStep({
actions,
direction,
selectedRuntimeId,
}: DefaultConfigStepProps) {
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<GlobalAgentConfig>(EMPTY_GLOBAL_CONFIG);
const [bakedEnv, setBakedEnv] = React.useState<BakedEnvEntry[]>([]);
const coalescerRef = React.useRef<{
enqueue: (value: GlobalAgentConfig) => void;
flush: () => Promise<void>;
cancel: () => void;
} | null>(null);
const [isSaving, setIsSaving] = React.useState(false);
const [configIsValid, setConfigIsValid] = React.useState(false);
const [isConfigLoading, setIsConfigLoading] = React.useState(true);
const [configValid, setConfigValid] = React.useState(false);
const [installResult, setInstallResult] = React.useState<InstallResultState>({
error: null,
success: false,
});
const installMutation = useInstallAcpRuntimeMutation();
const [completionError, setCompletionError] = React.useState<string | null>(
null,
);
const [isCompleting, setIsCompleting] = React.useState(false);
React.useEffect(() => {
let unmounted = false;
@@ -82,242 +565,171 @@ function AgentDefaultsSection({
if (unmounted) return;
if (configResult.status === "fulfilled") {
setConfig(configResult.value);
const harnessChanged =
selectedRuntimeId !== null &&
selectedRuntimeId !== configResult.value.preferred_runtime;
setConfig(
harnessChanged
? resetConfigForHarnessChange(configResult.value, selectedRuntimeId)
: configResult.value,
);
}
if (bakedEnvResult.status === "fulfilled") {
setBakedEnv(bakedEnvResult.value);
}
setIsLoading(false);
setIsConfigLoading(false);
}
void loadDefaults();
// 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<GlobalAgentConfig>(
// 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;
return () => {
unmounted = true;
coalescer.cancel();
};
}, []);
const effectiveReadyRuntimeIds = React.useMemo(
() =>
readyRuntimeIds.length > 0
? readyRuntimeIds
: getReadyOnboardingRuntimes(runtimesQuery.data ?? []).map(
(runtime) => runtime.id,
),
[readyRuntimeIds, runtimesQuery.data],
);
const readyRuntimeIdSet = React.useMemo(
() => new Set(effectiveReadyRuntimeIds),
[effectiveReadyRuntimeIds],
);
// 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],
);
const selectedRuntime = React.useMemo(
() =>
readyRuntimes.find((runtime) => runtime.id === config.preferred_runtime),
[config.preferred_runtime, readyRuntimes],
);
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],
);
const handleHarnessChange = React.useCallback(
(runtimeId: string) => {
const next = resetConfigForHarnessChange(config, runtimeId);
setIsCustomModelEditing(false);
setIsCustomProvider(false);
setConfig(next);
coalescerRef.current?.enqueue(next);
},
[config],
);
}, [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 (configSurfaceLoading || selectedRuntimeId) return;
if (readyRuntimes.length !== 1) return;
handleHarnessChange(readyRuntimes[0].id);
}, [
configSurfaceLoading,
handleHarnessChange,
readyRuntimes,
selectedRuntimeId,
]);
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 flushPersistence = React.useCallback(
() => coalescerRef.current?.flush() ?? Promise.resolve(),
[],
);
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,
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,
});
},
});
}, [
configIsValid,
flushPersistence,
isSaving,
onPersistenceStateChange,
selectedRuntimeId,
]);
return (
<section className="w-full space-y-4 text-left text-sm">
{configSurfaceLoading ? (
<div className="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground">
<Spinner className="h-4 w-4 border-2" />
Loading…
</div>
) : configSurfaceError ? (
<p className="py-4 text-center text-sm text-destructive">
Couldn't load harness settings. Go back and try again.
</p>
) : (
<div className="space-y-7">
<div className="space-y-4">
<label
className="pl-3 text-sm font-medium"
htmlFor="global-agent-default-harness"
>
Default harness
</label>
<AgentDropdownSelect
className="h-12 rounded-2xl border-foreground/15 bg-white px-4 py-2 text-sm shadow-none hover:bg-white/95"
id="global-agent-default-harness"
onValueChange={handleHarnessChange}
options={harnessOptions}
placeholder="Select a harness"
placeholderClassName="text-foreground/70"
testId="global-agent-default-harness"
value={selectedRuntimeId}
/>
</div>
<AgentConfigFields
bakedEnv={bakedEnv}
selectedRuntime={selectedRuntime}
config={config}
isCustomModelEditing={isCustomModelEditing}
isCustomProvider={isCustomProvider}
onConfigChange={(next) => {
// 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
/>
</div>
)}
</section>
);
}
/**
* 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,
}: DefaultConfigStepProps) {
const [persistenceState, setPersistenceState] = React.useState<{
canComplete: boolean;
flush: () => Promise<void>;
}>({ canComplete: false, flush: () => Promise.resolve() });
const [completionError, setCompletionError] = React.useState<string | null>(
null,
);
const [isCompleting, setIsCompleting] = React.useState(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 (
<OnboardingSlideTransition
className="flex min-h-full w-full flex-col items-center"
data-testid="onboarding-page-config"
direction={direction}
effect="fade"
transitionKey={`default-config-${direction}`}
>
<div className="w-full max-w-[500px] text-center">
<h1 className="text-title font-normal text-foreground">
Configure your default model settings
</h1>
<p className="mx-auto mt-3 max-w-[440px] text-sm leading-5 text-foreground/80">
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.
</p>
</div>
<SelectedRuntimeHeader runtime={selectedRuntime} />
<div className="flex w-full flex-1 items-center justify-center py-10">
<div className="w-full max-w-[328px]">
<AgentDefaultsSection
onPersistenceStateChange={setPersistenceState}
readyRuntimeIds={readyRuntimeIds}
/>
<div className="flex w-full flex-1 items-center justify-center py-6 [@media(max-height:560px)]:items-start [@media(max-height:560px)]:py-2">
<div className="w-full max-w-[760px]">
{effectivePlan === "loading" && selectedRuntime ? (
<LoadingSetup runtime={selectedRuntime} />
) : effectivePlan === "loading" ? (
<div className="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground">
<Spinner className="h-4 w-4 border-2" />
Loading…
</div>
) : effectivePlan === "catalog-error" ? (
<div className="flex flex-col items-center gap-3 py-4 text-center text-sm">
<p className="text-destructive">
Couldn't load harness settings. Try again.
</p>
<Button
className={`${ONBOARDING_PRIMARY_CTA_CLASS} text-sm`}
disabled={runtimesQuery.isFetching}
onClick={() => void runtimesQuery.refetch()}
type="button"
>
{runtimesQuery.isFetching ? "Checking…" : "Try again"}
</Button>
</div>
) : effectivePlan === "missing-runtime" ? (
<p className="py-4 text-center text-sm text-destructive">
Couldn't load this harness. Go back and try again.
</p>
) : selectedRuntime ? (
effectivePlan === "needs-install" ? (
<InstallSetup
installResult={installResult}
isInstalling={installMutation.isPending}
onInstall={handleInstall}
runtime={selectedRuntime}
/>
) : effectivePlan === "needs-sign-in" ? (
<SignInSetup runtime={selectedRuntime} />
) : effectivePlan === "auth-unknown" ? (
<AuthCheckSetup
label="Check again"
message="Buzz couldn't verify this harness yet. Check again after you've finished setup."
runtime={selectedRuntime}
/>
) : effectivePlan === "auth-invalid" ? (
<AuthCheckSetup
label="Check again"
message="This harness's configuration looks invalid. Fix it outside Buzz, then check again."
runtime={selectedRuntime}
/>
) : effectivePlan === "needs-provider-defaults" ? (
<ProviderDefaultsSetup
bakedEnv={bakedEnv}
config={config}
onConfigChange={setConfig}
onValidityChange={setConfigValid}
runtime={selectedRuntime}
runtimeFileConfig={runtimeFileConfig}
/>
) : (
<ReadySetup runtime={selectedRuntime} />
)
) : null}
{completionError ? (
<p
className="mt-3 text-center text-xs text-destructive"
@@ -333,11 +745,11 @@ export function DefaultConfigStep({
<Button
className={`${ONBOARDING_PRIMARY_CTA_CLASS} text-sm`}
data-testid="onboarding-finish"
disabled={!persistenceState.canComplete || isCompleting}
disabled={!canFinish || isCompleting}
onClick={() => void handleComplete()}
type="button"
>
Next
Finish
</Button>
<Button
@@ -0,0 +1,53 @@
import type * as React from "react";
import type { AcpRuntimeCatalogEntry } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Card, type CardProps } from "@/shared/ui/card";
import { getRuntimeDisplayLabel, RuntimeIcon } from "./RuntimeIcon";
/**
* Setup-page width that mirrors one column of the untouched chooser grid:
* the onboarding frame is min(viewport - page padding, 65rem), with three
* 1rem gaps divided across four columns. Do not apply this to the chooser —
* its grid remains the source of truth.
*/
export const HARNESS_SETUP_CARD_WIDTH_CLASS =
"w-full max-w-[288px] md:w-[calc((min(100vw-2rem,65rem)-3rem)/4)]";
type HarnessChoiceCardProps = Omit<CardProps, "variant"> & {
runtime: AcpRuntimeCatalogEntry;
};
/**
* The single visual for an onboarding harness card — icon + name on the
* textured surface. The chooser renders it interactive; the setup page
* renders it static. Both pages keep identical size and content.
*/
export function HarnessChoiceCard({
className,
runtime,
...cardProps
}: HarnessChoiceCardProps): React.JSX.Element {
return (
<Card
className={cn(
// Height compresses on short windows (800×500 minimum) so the grid +
// docked footer fit without clipping. The textured variant's
// min-height floor must drop with it (see card-texture.css — compact
// cards opt in via the variable). Chooser and setup page share this
// class, keeping the card-travel transition dimension-stable.
"h-[180px] w-full select-none items-center px-3 py-1.5 text-center [--buzz-card-textured-min-height:180px] [@media(max-height:560px)]:h-[120px] [@media(max-height:560px)]:[--buzz-card-textured-min-height:120px]",
className,
)}
variant="textured"
{...cardProps}
>
<div className="flex min-w-0 flex-col items-center gap-3">
<RuntimeIcon className="h-8 w-8" runtime={runtime} />
<h2 className="truncate text-sm font-normal leading-5 text-foreground">
{getRuntimeDisplayLabel(runtime)}
</h2>
</div>
</Card>
);
}
@@ -64,13 +64,9 @@ export function MachineOnboardingFlow({
const [selectedPubkey, setSelectedPubkey] = React.useState<string | null>(
null,
);
const [readyRuntimeIds, setReadyRuntimeIds] = React.useState<string[]>([]);
const handleReadyRuntimeIdsChange = React.useCallback(
(runtimeIds: readonly string[]) => {
setReadyRuntimeIds(Array.from(new Set(runtimeIds)));
},
[],
);
const [selectedRuntimeId, setSelectedRuntimeId] = React.useState<
string | null
>(null);
const loadFreshIdentity = React.useCallback(async () => {
setIsPending(true);
@@ -234,17 +230,11 @@ export function MachineOnboardingFlow({
actions={{
back: () =>
setPage(identityWasImported ? "key-import" : "backup"),
next: (runtimeIds) => {
const ids = Array.from(runtimeIds);
setReadyRuntimeIds(ids);
// Harness install can fail (Windows/PATH/network). Don't soft-lock
// onboarding — users can finish setup later in Settings → Agents.
if (ids.length === 0) {
complete(selectedPubkey ?? undefined);
return;
}
next: (runtimeId) => {
setSelectedRuntimeId(runtimeId);
setPage("config");
},
skip: () => complete(selectedPubkey ?? undefined),
navigateToAgentSettings: () => {
// Complete onboarding first, then delegate the Settings → Agents
// navigation to the parent. The parent owns RouterProvider, so
@@ -258,18 +248,18 @@ export function MachineOnboardingFlow({
},
}}
direction="forward"
onReadyRuntimeIdsChange={handleReadyRuntimeIdsChange}
selectedRuntimeId={selectedRuntimeId}
/>
) : (
) : page === "config" ? (
<DefaultConfigStep
actions={{
back: () => setPage("setup"),
complete: () => complete(selectedPubkey ?? undefined),
}}
direction="forward"
readyRuntimeIds={readyRuntimeIds}
selectedRuntimeId={selectedRuntimeId}
/>
)}
) : null}
</div>
</OnboardingFooterProvider>
</div>
+91 -572
View File
@@ -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<string, InstallResultState>;
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 (
<span
aria-hidden="true"
className="pointer-events-none absolute right-8 top-8 flex h-8 w-8 items-center justify-center rounded-full border border-[var(--buzz-welcome-chartreuse)] bg-[var(--buzz-welcome-chartreuse)]"
data-testid={`onboarding-runtime-check-${runtime.id}`}
>
<Check
className="h-4 w-4 text-foreground"
data-testid={`onboarding-runtime-checkmark-${runtime.id}`}
strokeWidth={3}
/>
</span>
);
}
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 (
<div className="flex flex-col items-center gap-1.5">
<Button
aria-label={`Sign in to ${runtime.label}`}
className="buzz-onboarding-runtime-setup h-5 rounded-full bg-[var(--buzz-welcome-chartreuse)]/30 px-2.5 font-mono !text-badge font-normal uppercase text-foreground hover:bg-[var(--buzz-welcome-chartreuse)]/40"
data-testid={`onboarding-runtime-instructions-${runtime.id}`}
onClick={() => {
if (didSignInCheckTimeOut) {
setDidSignInCheckTimeOut(false);
setIsWaitingForSignIn(true);
void runtimesQuery.refetch();
return;
}
if (!authMethod) {
void methodsQuery.refetch();
return;
}
connectMutation.mutate(
{
methodId: authMethod.id,
runtimeId: runtime.id,
},
{
onSuccess: () => setIsWaitingForSignIn(true),
},
);
}}
type="button"
variant="ghost"
>
{isWaitingForSignIn
? "CHECKING…"
: didSignInCheckTimeOut
? "CHECK AGAIN"
: "SIGN IN"}
</Button>
{methodsQuery.error instanceof Error ? (
<RuntimeErrorTooltip
className="absolute inset-x-3 bottom-2 truncate text-xs leading-4 text-destructive"
detail="Couldn’t load sign-in options."
label="Sign-in unavailable"
/>
) : null}
{connectMutation.error instanceof Error ? (
<RuntimeErrorTooltip
className="absolute inset-x-3 bottom-2 truncate text-xs leading-4 text-destructive"
detail="Couldn’t start sign-in. Try again."
label="Sign-in failed"
/>
) : null}
</div>
);
}
if (isInstalling) {
return (
<div
aria-label={`Installing ${runtime.label}`}
className="flex h-5 items-center gap-2 rounded-full bg-white/60 px-2.5 font-mono text-badge font-normal uppercase text-foreground"
role="status"
>
<Spinner className="h-3 w-3 border-2 text-foreground" />
INSTALLING
</div>
);
}
if (runtimeIsReadyForOnboarding(runtime)) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-flex h-5 cursor-default items-center rounded-full bg-[#EBEFEF] px-2.5 font-mono text-badge font-normal uppercase text-foreground"
data-testid={`onboarding-runtime-ready-${runtime.id}`}
>
READY
</span>
</TooltipTrigger>
<TooltipContent
className="max-w-80 bg-black text-left text-xs text-white shadow-sm"
side="top"
>
<RuntimeDetails runtime={runtime} />
</TooltipContent>
</Tooltip>
);
}
if (
runtime.availability === "available" &&
runtime.authStatus.status === "unknown"
) {
return (
<Button
aria-label={`Check ${runtime.label} again`}
className="buzz-onboarding-runtime-setup h-5 rounded-full bg-[var(--buzz-welcome-chartreuse)]/30 px-2.5 font-mono !text-badge font-normal uppercase text-foreground hover:bg-[var(--buzz-welcome-chartreuse)]/40"
disabled={runtimesQuery.isFetching}
onClick={() => void runtimesQuery.refetch()}
type="button"
variant="ghost"
>
{runtimesQuery.isFetching ? "CHECKING…" : "CHECK AGAIN"}
</Button>
);
}
const installLabel = installError ? "RETRY INSTALL" : "INSTALL";
if (runtime.canAutoInstall) {
return (
<Button
aria-label={`${installError ? "Retry installing" : "Install"} ${runtime.label}`}
className="buzz-onboarding-runtime-setup h-5 rounded-full bg-[var(--buzz-welcome-chartreuse)]/30 px-2.5 font-mono !text-badge font-normal uppercase text-foreground hover:bg-[var(--buzz-welcome-chartreuse)]/40"
data-testid={`onboarding-runtime-install-${runtime.id}`}
onClick={onInstall}
type="button"
variant="ghost"
>
{installLabel}
</Button>
);
}
return (
<Button
aria-label={`View ${runtime.label} install instructions`}
className="buzz-onboarding-runtime-setup h-5 rounded-full bg-[var(--buzz-welcome-chartreuse)]/30 px-2.5 font-mono !text-badge font-normal uppercase text-foreground hover:bg-[var(--buzz-welcome-chartreuse)]/40"
data-testid={`onboarding-runtime-instructions-${runtime.id}`}
onClick={() => void openUrl(runtime.installInstructionsUrl)}
type="button"
variant="ghost"
>
INSTALL
</Button>
);
}
function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
if (
runtime.availability === "available" &&
runtime.command &&
runtime.binaryPath
) {
const description = describeResolvedCommand(
runtime.command,
runtime.binaryPath,
);
return (
<>
<p className="text-xs leading-4 text-white">
{description.charAt(0).toUpperCase() + description.slice(1)}
</p>
{runtime.defaultArgs.length > 0 ? (
<p className="mt-1 text-xs leading-4 text-white">
Args:{" "}
<code className="font-mono">{runtime.defaultArgs.join(", ")}</code>
</p>
) : null}
</>
);
}
if (runtime.availability === "adapter_missing") {
return (
<>
<p className="text-xs leading-4 text-white">
CLI detected; ACP adapter missing.
</p>
<p className="mt-1 text-xs leading-4 text-white">
{runtime.installHint}
</p>
</>
);
}
if (runtime.availability === "adapter_outdated") {
return (
<>
<p className="text-xs leading-4 text-white">
ACP adapter detected but outdated — reinstall required.
</p>
<p className="mt-1 text-xs leading-4 text-white">
This updates the machine-global{" "}
<code className="rounded bg-white/10 px-0.5 font-mono text-xs text-white">
codex-acp
</code>{" "}
adapter. Older Buzz releases using the legacy adapter contract may
lose community access until{" "}
<code className="rounded bg-white/10 px-0.5 font-mono text-xs text-white">
@zed-industries/codex-acp@0.16.0
</code>{" "}
is restored.
</p>
<p className="mt-1 text-xs leading-4 text-white">
{runtime.installHint}
</p>
</>
);
}
if (runtime.availability === "cli_missing") {
return (
<>
<p className="text-xs leading-4 text-white">
ACP adapter detected; CLI missing.
</p>
<p className="mt-1 text-xs leading-4 text-white">
{runtime.installHint}
</p>
</>
);
}
return (
<>
<p className="text-xs leading-4 text-white">Not installed yet.</p>
<p className="mt-1 text-xs leading-4 text-white">{runtime.installHint}</p>
</>
);
}
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 (
<RuntimeErrorTooltip
className="absolute inset-x-3 bottom-2 truncate text-xs leading-4 text-destructive"
detail="Check this runtime’s configuration and try again."
label="Configuration invalid"
/>
);
}
if (
runtime.availability === "available" &&
runtime.authStatus.status === "unknown"
) {
return (
<RuntimeErrorTooltip
className="absolute inset-x-3 bottom-2 truncate text-xs leading-4 text-destructive"
detail="Couldn’t verify authentication."
label="Status unavailable"
/>
);
}
return null;
}
function RuntimeCard({
installResults,
onInstallResultsChange,
runtime,
}: {
installResults: InstallResultsState;
onInstallResultsChange: React.Dispatch<
React.SetStateAction<InstallResultsState>
>;
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 (
<Card
<div
className={cn(
"group h-[224px] w-full max-w-[288px] select-none items-center px-3 py-1.5 text-center",
installError && "ring-1 ring-destructive/40",
isReady && "brightness-[0.98]",
"relative w-full max-w-[288px] transition-opacity duration-200",
dimmed && "pointer-events-none opacity-0",
)}
data-ready={isReady ? "true" : "false"}
data-testid={`onboarding-runtime-${runtime.id}`}
variant="textured"
>
<RuntimeReadinessIndicator ready={isReady} runtime={runtime} />
<div className="flex min-w-0 flex-col items-center gap-2.5">
<div className="flex min-w-0 items-center justify-center gap-3">
<RuntimeIcon className="h-7 w-7" runtime={runtime} />
<h2 className="truncate text-sm font-normal leading-5 text-foreground">
{getRuntimeDisplayLabel(runtime)}
</h2>
</div>
<RuntimeStatus
installError={installError}
isInstalling={isInstalling}
onInstall={handleInstall}
runtime={runtime}
/>
{!isAvailable && runtimeDetailText(runtime) ? (
<p
aria-hidden={installError ? "true" : undefined}
className={cn(
"max-w-[13rem] text-2xs leading-4 text-muted-foreground",
installError && "invisible",
)}
>
{runtimeDetailText(runtime)}
</p>
) : null}
</div>
{installError ? (
<RuntimeErrorTooltip
className="absolute inset-x-3 bottom-2 flex min-w-0 items-center justify-center gap-1.5 overflow-hidden whitespace-nowrap text-xs leading-4 text-destructive"
detail={installError}
label="Installation failed"
showIcon
testId={`onboarding-runtime-error-${runtime.id}`}
/>
) : (
<RuntimeAuthError runtime={runtime} />
)}
</Card>
<HarnessChoiceCard
aria-pressed={selected}
className="cursor-pointer transition duration-150 hover:-translate-y-0.5 hover:brightness-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/35"
data-selected={selected ? "true" : "false"}
data-testid={`onboarding-runtime-${runtime.id}`}
onClick={onChoose}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
onChoose();
}}
role="button"
runtime={runtime}
tabIndex={0}
/>
</div>
);
}
@@ -588,40 +100,45 @@ function RuntimeProvidersLoadingState() {
}
function RuntimeProvidersSection({
installResults,
onInstallResultsChange,
onChooseRuntime,
pendingRuntimeId,
runtimeProviders,
selectedRuntimeId,
}: {
installResults: InstallResultsState;
onInstallResultsChange: React.Dispatch<
React.SetStateAction<InstallResultsState>
>;
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 (
<section className="flex min-h-full w-full flex-col items-center">
<div className="w-full max-w-[820px] text-center">
<h1 className="text-title font-normal text-foreground">
Set up your agent harnesses
Choose your default harness
</h1>
<p className="mx-auto mt-3 max-w-[760px] text-sm leading-6 text-foreground/90">
Buzz checks for command-line harnesses on this machine. Install the
CLI or sign in to at least one to continue.
<p className="mx-auto mt-3 max-w-[640px] text-sm leading-6 text-foreground/90">
Pick the harness Buzz should use by default. You can always connect
and add more harnesses later in Settings.
</p>
</div>
<div className="flex w-full flex-1 flex-col items-center justify-center gap-8 py-10">
<div className="flex w-full flex-1 flex-col items-center justify-center gap-8 py-6 [@media(max-height:560px)]:py-2">
{orderedItems.length > 0 ? (
<div className="grid min-w-0 w-full max-w-[1200px] grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
// 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.
<div className="grid min-w-0 w-full max-w-[1200px] grid-cols-1 justify-items-center gap-4 md:grid-cols-4">
{orderedItems.map((runtime) => (
<RuntimeCard
installResults={installResults}
<RuntimeChoiceCard
dimmed={pendingRuntimeId !== null}
key={runtime.id}
onInstallResultsChange={onInstallResultsChange}
onChoose={() => onChooseRuntime(runtime.id)}
runtime={runtime}
selected={runtime.id === highlightedRuntimeId}
/>
))}
</div>
@@ -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.
</p>
)}
@@ -650,55 +167,57 @@ function RuntimeProvidersSection({
function SetupStepContent({
actions,
direction,
onReadyRuntimeIdsChange,
selectedRuntimeId,
state,
}: SetupStepContentProps) {
const { runtimeProviders } = state;
const [installResults, setInstallResults] =
React.useState<InstallResultsState>({});
const readyRuntimeIds = React.useMemo(
() =>
getReadyOnboardingRuntimes(runtimeProviders.items).map(
(runtime) => runtime.id,
),
[runtimeProviders.items],
const shouldReduceMotion = useReducedMotion() ?? false;
const [pendingRuntimeId, setPendingRuntimeId] = React.useState<string | null>(
null,
);
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
// 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],
);
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 (
<OnboardingSlideTransition
className="flex min-h-full w-full flex-col items-center"
data-testid="onboarding-page-2"
direction={direction}
effect="fade"
transitionKey={`setup-${direction}`}
>
<RuntimeProvidersSection
installResults={installResults}
onInstallResultsChange={setInstallResults}
runtimeProviders={runtimeProviders}
onChooseRuntime={chooseRuntime}
pendingRuntimeId={pendingRuntimeId}
runtimeProviders={state.runtimeProviders}
selectedRuntimeId={selectedRuntimeId}
/>
<OnboardingFooter>
<Button
className={`${ONBOARDING_PRIMARY_CTA_CLASS} text-sm`}
data-testid="onboarding-setup-next"
disabled={readyRuntimeIds.length === 0}
onClick={() => actions.next(readyRuntimeIds)}
type="button"
>
Next
</Button>
<Button
className="h-9 rounded-full bg-foreground/10 px-6 text-sm hover:bg-foreground/15"
data-testid="onboarding-setup-skip"
onClick={() => actions.next([])}
onClick={actions.skip}
type="button"
variant="ghost"
>
@@ -739,7 +258,7 @@ function SetupStepContent({
export function SetupStep({
actions,
direction,
onReadyRuntimeIdsChange,
selectedRuntimeId,
}: SetupStepProps) {
const state = useSetupStepState();
@@ -747,7 +266,7 @@ export function SetupStep({
<SetupStepContent
actions={actions}
direction={direction}
onReadyRuntimeIdsChange={onReadyRuntimeIdsChange}
selectedRuntimeId={selectedRuntimeId}
state={state}
/>
);
+2 -1
View File
@@ -58,7 +58,8 @@ export type ProfileStepActions = {
export type SetupStepActions = {
back: () => void;
next: (readyRuntimeIds: readonly string[]) => void;
next: (runtimeId: string) => void;
skip: () => void;
navigateToAgentSettings?: () => void;
};
+6 -1
View File
@@ -190,6 +190,8 @@ type E2eConfig = {
acpRuntimesCatalogAfterInstallSequence?: RawAcpRuntimeCatalogEntry[][];
/** Catalog responses for successive discovery calls. The final response repeats. */
acpRuntimesCatalogSequence?: RawAcpRuntimeCatalogEntry[][];
/** Sequenced discovery failures. Null succeeds; a string throws. */
acpRuntimesErrors?: (string | null)[];
acpRuntimesDelayMs?: number;
acpAuthMethods?: Record<string, RawAcpAuthMethodsResult>;
acpAuthMethodsErrors?: Record<string, string>;
@@ -10416,8 +10418,11 @@ export function maybeInstallE2eTauriMocks() {
return getRelayHttpUrl(activeConfig);
case "relay_requires_membership":
return activeConfig?.mock?.relayRequiresMembership ?? false;
case "discover_acp_providers":
case "discover_acp_providers": {
const error = activeConfig?.mock?.acpRuntimesErrors?.shift();
if (error) throw new Error(error);
return handleDiscoverAcpRuntimes(activeConfig);
}
case "save_custom_harness":
return handleSaveCustomHarness(
payload as Parameters<typeof handleSaveCustomHarness>[0],
File diff suppressed because it is too large Load Diff
+2
View File
@@ -162,6 +162,8 @@ type MockBridgeOptions = {
acpRuntimesCatalogAfterInstallSequence?: Record<string, unknown>[][];
/** Catalog responses for successive discovery calls. The final response repeats. */
acpRuntimesCatalogSequence?: Record<string, unknown>[][];
/** Sequenced discovery failures. Null succeeds; a string throws. */
acpRuntimesErrors?: (string | null)[];
acpRuntimesDelayMs?: number;
acpAuthMethods?: Record<string, { methods: Record<string, unknown>[] }>;
acpAuthMethodsError?: string;