mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Rework onboarding around one default harness
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user