feat(desktop): reskin provider, config, community, profile, and team onboarding pages (#2003)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-16 19:49:45 -07:00
committed by GitHub
co-authored by Pinky Brain
parent 64c63daf02
commit f054df7366
9 changed files with 647 additions and 389 deletions
@@ -23,8 +23,11 @@ import {
BLOCK_BUILD_HIDDEN_PROVIDER_IDS,
CUSTOM_PROVIDER_DROPDOWN_VALUE,
getPersonaProviderOptions,
getProviderApiKeyEnvVar,
requiredCredentialEnvKeys,
} from "@/features/agents/ui/personaDialogPickers";
import { AgentModelField } from "@/features/agents/ui/personaProviderModelFields";
import { PersonaProviderApiKeyField } from "@/features/agents/ui/PersonaProviderApiKeyField";
import { usePersonaModelDiscovery } from "@/features/agents/ui/usePersonaModelDiscovery";
import {
BUZZ_AGENT_THINKING_EFFORT,
@@ -100,6 +103,25 @@ export function GlobalAgentConfigFields({
const providerValue = config.provider ?? "";
const providerForDiscovery = isCustomProvider ? "" : providerValue;
const credentialProvider = isCustomProvider ? "" : effectiveProvider;
const requiredEnvKeys = requiredCredentialEnvKeys(
"buzz-agent",
credentialProvider,
);
const apiKeyEnvVar = getProviderApiKeyEnvVar(credentialProvider);
const advancedRequiredEnvKeys = requiredEnvKeys.filter(
(key) =>
key !== apiKeyEnvVar && !bakedEnv.some((entry) => entry.key === key),
);
const apiKeyValue = apiKeyEnvVar ? (config.env_vars[apiKeyEnvVar] ?? "") : "";
const bakedEnvKeys = React.useMemo(
() => bakedEnv.map((entry) => entry.key),
[bakedEnv],
);
const apiKeyInherited =
apiKeyEnvVar !== null &&
apiKeyValue.length === 0 &&
bakedEnvKeys.includes(apiKeyEnvVar);
const {
discoveredModelOptions,
@@ -131,21 +153,32 @@ export function GlobalAgentConfigFields({
});
function handleProviderChange(value: string) {
const previousApiKey = getProviderApiKeyEnvVar(effectiveProvider);
if (value === CUSTOM_PROVIDER_DROPDOWN_VALUE) {
const nextEnvVars = { ...config.env_vars };
if (previousApiKey) delete nextEnvVars[previousApiKey];
onIsCustomProviderChange(true);
onConfigChange({ ...config, env_vars: nextEnvVars, provider: null });
return;
}
if (value === AUTO_PROVIDER_DROPDOWN_VALUE || value === "") {
onIsCustomProviderChange(false);
onConfigChange({ ...config, provider: null });
} else {
onIsCustomProviderChange(false);
onConfigChange({
...config,
provider: value,
model: value === "relay-mesh" ? config.model || "auto" : config.model,
});
const nextProvider =
value === AUTO_PROVIDER_DROPDOWN_VALUE || value === "" ? null : value;
const nextApiKey = getProviderApiKeyEnvVar(
nextProvider ?? bakedProvider ?? "",
);
const nextEnvVars = { ...config.env_vars };
if (previousApiKey && previousApiKey !== nextApiKey) {
delete nextEnvVars[previousApiKey];
}
onIsCustomProviderChange(false);
onConfigChange({
...config,
env_vars: nextEnvVars,
provider: nextProvider,
model:
nextProvider === "relay-mesh" ? config.model || "auto" : config.model,
});
}
function handleCustomProviderInput(value: string) {
@@ -168,10 +201,6 @@ export function GlobalAgentConfigFields({
onConfigChange({ ...config, env_vars: merged });
}
const bakedEnvKeys = React.useMemo(
() => bakedEnv.map((e) => e.key),
[bakedEnv],
);
// On internal Block builds, BUZZ_AGENT_PROVIDER is baked in and a boot
// migration rewrites v1→v2. Hide the legacy v1 option so it is not offered
// for new selections; OSS builds show it.
@@ -237,6 +266,29 @@ export function GlobalAgentConfigFields({
</p>
</div>
{apiKeyEnvVar ? (
<div className="p-3">
<PersonaProviderApiKeyField
disabled={false}
inheritedLabel="Provided by this build"
isInherited={apiKeyInherited}
isRequired={!apiKeyInherited && apiKeyValue.length === 0}
label={
effectiveProvider === "anthropic"
? "Anthropic API Key"
: "OpenAI API Key"
}
onValueChange={(value) =>
onConfigChange({
...config,
env_vars: { ...config.env_vars, [apiKeyEnvVar]: value },
})
}
value={apiKeyValue}
/>
</div>
) : null}
{/* Model field */}
<div className="space-y-1.5 p-3">
<AgentModelField
@@ -295,10 +347,12 @@ export function GlobalAgentConfigFields({
<div className="p-3">
<EnvVarsEditor
helperText="Injected into all agents as the lowest-priority layer. Per-agent values override these."
hiddenKeys={apiKeyEnvVar ? [apiKeyEnvVar] : []}
inheritedRows={bakedGenericRows}
inheritedRowsLabel="build"
label="Global environment variables"
onChange={handleEnvVarsChange}
requiredKeys={advancedRequiredEnvKeys}
value={Object.fromEntries(
Object.entries(config.env_vars).filter(
([k]) => k !== BUZZ_AGENT_THINKING_EFFORT,
@@ -13,6 +13,7 @@ import { pubkeyToNpub } from "@/shared/lib/nostrUtils";
import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
import { OnboardingStepDots } from "@/features/onboarding/ui/OnboardingStepDots";
type WelcomeSetupPage = "welcome" | "join" | "invite";
type WelcomeTransitionMode = "initial" | OnboardingTransitionDirection;
@@ -94,7 +95,8 @@ export function WelcomeSetup({
data-system-color-scheme={systemColorScheme}
>
<StartupWindowDragRegion />
<div className="relative flex w-full max-w-[500px] flex-col items-center text-center">
<OnboardingStepDots current={5} />
<div className="relative flex w-full max-w-[760px] flex-col items-center text-center">
{page === "welcome" ? (
<OnboardingSlideTransition
className="flex w-full flex-col items-center text-center"
@@ -102,51 +104,45 @@ export function WelcomeSetup({
effect={welcomeEffect}
transitionKey={`welcome-${welcomeEffect}-${transitionDirection}`}
>
<img
alt="Buzz"
className="h-14 w-14 rounded-xl shadow-xs"
src="/app-icon@2x.png"
srcSet="/app-icon@2x.png 1x, /app-icon@3x.png 2x"
/>
<h1 className="mt-6 text-3xl font-semibold tracking-tight">
Welcome to Buzz
</h1>
<p className="mt-3 max-w-[440px] text-sm leading-6 text-muted-foreground">
Choose how you want to get started.
</p>
<div className="mt-8 flex w-full flex-col gap-3">
<div className="w-full max-w-[440px]">
<h1 className="text-3xl font-semibold tracking-tight">
Join or create a community
</h1>
<p className="mt-3 text-sm leading-6 text-foreground/80">
Request access to an existing community or create a new one.
</p>
</div>
<div className="mt-14 flex w-full flex-wrap items-stretch justify-center gap-4">
{isLocalDevRelayUrl(defaultRelayUrl) ? null : (
<Button
className="h-10 w-full"
<button
className="flex min-h-32 w-44 items-center justify-center rounded-2xl bg-white/85 p-4 text-sm font-medium text-foreground shadow-[0_0_45px_18px_rgba(255,255,255,0.65)] transition-shadow hover:shadow-[0_0_55px_25px_rgba(255,255,255,0.85)]"
onClick={handleDefaultCommunity}
type="button"
>
Join default community
</Button>
</button>
)}
<Button
className="h-10 w-full"
<button
className="flex min-h-32 w-44 items-center justify-center rounded-2xl bg-white/85 p-4 text-sm font-medium text-foreground shadow-[0_0_45px_18px_rgba(255,255,255,0.65)] transition-shadow hover:shadow-[0_0_55px_25px_rgba(255,255,255,0.85)]"
onClick={() => showPage("join")}
type="button"
>
Join a community
</Button>
<Button
className="h-10 w-full"
</button>
<button
className="flex min-h-32 w-44 items-center justify-center rounded-2xl bg-white/85 p-4 text-sm font-medium text-foreground shadow-[0_0_45px_18px_rgba(255,255,255,0.65)] transition-shadow hover:shadow-[0_0_55px_25px_rgba(255,255,255,0.85)]"
onClick={() => showPage("invite")}
type="button"
variant="secondary"
>
I have an invite link
</Button>
<Button
className="h-10 w-full"
</button>
<button
className="flex min-h-32 w-44 items-center justify-center rounded-2xl bg-white/85 p-4 text-sm font-medium text-foreground shadow-[0_0_45px_18px_rgba(255,255,255,0.65)] transition-shadow hover:shadow-[0_0_55px_25px_rgba(255,255,255,0.85)]"
onClick={() => void openUrl(CREATE_COMMUNITY_URL)}
type="button"
variant="ghost"
>
Create a community
</Button>
</button>
</div>
</OnboardingSlideTransition>
) : page === "join" ? (
@@ -1,6 +1,6 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Bot, Users } from "lucide-react";
import { Plus, Users, X } from "lucide-react";
import {
markCommunityOnboardingComplete,
@@ -8,15 +8,69 @@ import {
} from "@/features/onboarding/communityOnboarding";
import { initializeStarterChannels } from "@/features/onboarding/hooks";
import { useClaimInvite } from "@/features/onboarding/useClaimInvite";
import { AvatarUpload } from "@/features/profile/ui/AvatarUpload";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import {
parseEmojiAvatarDataUrl,
ProfileAvatarEditor,
} from "@/features/profile/ui/ProfileAvatarEditor";
import { updateProfile } from "@/shared/api/tauriProfiles";
import { getIdentity } from "@/shared/api/tauriIdentity";
import { listPersonas } from "@/shared/api/tauriPersonas";
import type { AgentPersona } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { OnboardingStepDots } from "./OnboardingStepDots";
const NEUTRAL_EMOJI_PICKER_THEME_VARS = {
"--buzz-emoji-picker-rgb-background":
"var(--buzz-onboarding-emoji-picker-background)",
"--buzz-emoji-picker-rgb-color": "var(--buzz-onboarding-emoji-picker-color)",
"--buzz-emoji-picker-rgb-input": "var(--buzz-onboarding-emoji-picker-input)",
} as React.CSSProperties;
function AvatarCircle({
avatarUrl,
onClick,
previewName,
}: {
avatarUrl: string;
onClick: () => void;
previewName: string;
}) {
const emojiAvatar = parseEmojiAvatarDataUrl(avatarUrl);
const hasAvatar = avatarUrl.trim().length > 0;
return (
<button
aria-label={hasAvatar ? "Change your avatar" : "Add an avatar"}
className="group mx-auto block rounded-full"
data-testid="community-avatar-open"
onClick={onClick}
type="button"
>
{emojiAvatar ? (
<span
className="flex h-28 w-28 items-center justify-center overflow-hidden rounded-full text-5xl shadow-xs"
style={{ backgroundColor: emojiAvatar.color }}
>
{emojiAvatar.emoji}
</span>
) : hasAvatar ? (
<ProfileAvatar
avatarUrl={avatarUrl}
className="h-28 w-28 rounded-full text-3xl"
label={previewName}
/>
) : (
<span className="flex h-28 w-28 items-center justify-center rounded-full bg-white/60 text-foreground/60 shadow-[0_0_35px_12px_rgba(255,255,255,0.5)] transition-colors group-hover:bg-white/80">
<Plus className="h-8 w-8" aria-hidden="true" />
</span>
)}
</button>
);
}
export function CommunityOnboardingFlow({
onConnect,
@@ -28,6 +82,7 @@ export function CommunityOnboardingFlow({
const [displayName, setDisplayName] = React.useState("");
const [avatarUrl, setAvatarUrl] = React.useState("");
const [isUploadingAvatar, setIsUploadingAvatar] = React.useState(false);
const [isAvatarEditorOpen, setIsAvatarEditorOpen] = React.useState(false);
const [starterPersonas, setStarterPersonas] = React.useState<AgentPersona[]>(
[],
);
@@ -102,13 +157,25 @@ export function CommunityOnboardingFlow({
}
};
const isProfileStage = transaction.stage === "profile";
const isTeamStage =
transaction.stage === "team-intro" || transaction.stage === "finalizing";
return (
<div
className="buzz-onboarding-neutral-theme buzz-startup-shell flex items-center justify-center bg-background px-4 py-8 text-foreground"
className="buzz-onboarding-neutral-theme buzz-startup-shell flex max-h-dvh items-start justify-center overflow-y-auto px-4 py-16 text-foreground"
data-testid="community-onboarding-flow"
>
<StartupWindowDragRegion />
<div className="w-full max-w-[440px] text-center">
{isProfileStage || isTeamStage ? (
<OnboardingStepDots current={isTeamStage ? 7 : 6} />
) : null}
<div
className={cn(
"relative my-auto w-full text-center",
isTeamStage ? "max-w-[760px]" : "max-w-[560px]",
)}
>
{transaction.stage === "claiming" ||
transaction.stage === "connecting" ? (
<>
@@ -116,7 +183,7 @@ export function CommunityOnboardingFlow({
<h1 className="mt-5 text-3xl font-semibold">
Joining {transaction.communityName}
</h1>
<p className="mt-3 text-sm text-muted-foreground">
<p className="mt-3 text-sm text-foreground/80">
{transaction.error ??
(transaction.stage === "claiming"
? "Accepting your invite…"
@@ -124,74 +191,113 @@ export function CommunityOnboardingFlow({
</p>
<div className="mt-6 flex justify-center gap-3">
{transaction.error ? (
<Button onClick={retryClaim}>Retry</Button>
<Button className="rounded-full px-6" onClick={retryClaim}>
Retry
</Button>
) : null}
<Button onClick={clear} variant="secondary">
<Button
className="rounded-full bg-foreground/10 px-5 hover:bg-foreground/15"
onClick={clear}
variant="ghost"
>
Cancel
</Button>
</div>
</>
) : transaction.stage === "profile" ? (
<>
<h1 className="text-3xl font-semibold">
How should you appear here?
</h1>
<p className="mt-3 text-sm text-muted-foreground">
Your name and avatar are specific to {transaction.communityName}.
</p>
<div className="mt-8 space-y-3 text-left">
<Input
aria-label="Community display name"
autoFocus
onChange={(event) => setDisplayName(event.target.value)}
placeholder="Your name"
value={displayName}
/>
<AvatarUpload
) : isProfileStage ? (
isAvatarEditorOpen ? (
<div className="relative rounded-3xl bg-white/85 px-6 py-8 shadow-[0_0_80px_50px_rgba(255,255,255,0.85)]">
<Button
aria-label="Close avatar editor"
className="absolute -right-3 -top-3 h-9 w-9 rounded-full"
data-testid="community-avatar-close"
onClick={() => setIsAvatarEditorOpen(false)}
size="icon"
type="button"
>
<X className="h-4 w-4" />
</Button>
<ProfileAvatarEditor
avatarUrl={avatarUrl}
disabled={isPending}
onClear={() => setAvatarUrl("")}
emojiPickerTheme="auto"
emojiPickerThemeVars={NEUTRAL_EMOJI_PICKER_THEME_VARS}
onDone={() => setIsAvatarEditorOpen(false)}
onUploadingChange={setIsUploadingAvatar}
onUrlChange={setAvatarUrl}
previewName={displayName.trim() || "Your profile"}
showClear={avatarUrl.length > 0}
testIdPrefix="community-avatar"
/>
{transaction.error ? (
<p className="text-sm text-destructive">{transaction.error}</p>
) : null}
<Button
className="w-full"
disabled={!displayName.trim() || isPending || isUploadingAvatar}
onClick={() => void saveProfile()}
>
Continue
</Button>
</div>
</>
) : (
<>
<h1 className="text-3xl font-semibold">Build your profile</h1>
<p className="mx-auto mt-3 max-w-[380px] text-sm leading-6 text-foreground/80">
Add a name and avatar. Theyll show up on your messages,
reactions, and agent handoffs.
</p>
<div className="mt-12">
<AvatarCircle
avatarUrl={avatarUrl}
onClick={() => setIsAvatarEditorOpen(true)}
previewName={displayName.trim() || "Your profile"}
/>
</div>
<div className="mx-auto mt-8 w-full max-w-[300px] text-left">
<label
className="text-sm font-medium"
htmlFor="community-display-name"
>
Your name
</label>
<Input
aria-label="Community display name"
autoFocus
className="mt-1.5 h-10 rounded-full bg-white/90 px-4"
id="community-display-name"
onChange={(event) => setDisplayName(event.target.value)}
placeholder="First and last name"
value={displayName}
/>
</div>
{transaction.error ? (
<p className="mt-4 text-sm text-destructive">
{transaction.error}
</p>
) : null}
<div className="mt-12 flex flex-col items-center gap-3">
<Button
className="h-10 rounded-full px-8"
disabled={
!displayName.trim() || isPending || isUploadingAvatar
}
onClick={() => void saveProfile()}
>
Continue
</Button>
</div>
</>
)
) : (
<>
<Bot className="mx-auto h-10 w-10" />
<h1 className="mt-5 text-3xl font-semibold">
Meet your starter team
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Fizz helps you build, Honey helps you communicate, and Bumble
helps you research. Theyll be ready when you need them.
<h1 className="text-3xl font-semibold">Meet your starter team</h1>
<p className="mx-auto mt-3 max-w-[400px] text-sm leading-6 text-foreground/80">
Buzz lets you bring multiple agents into the same workspace. This
team will help you get started using Buzz.
</p>
{starterPersonas.length > 0 ? (
<div className="mt-7 flex justify-center gap-5">
<div className="mt-10 flex flex-wrap justify-center gap-8">
{starterPersonas.map((persona) => (
<div
className="flex w-20 flex-col items-center gap-2"
className="flex w-36 flex-col items-center gap-3"
key={persona.id}
>
<ProfileAvatar
avatarUrl={persona.avatarUrl}
className="h-14 w-14"
className="h-28 w-28 text-3xl"
label={persona.displayName}
/>
<span className="text-sm font-medium">
<span className="font-mono text-xs font-medium uppercase tracking-[0.15em]">
{persona.displayName}
</span>
</div>
@@ -203,9 +309,9 @@ export function CommunityOnboardingFlow({
{transaction.error}
</p>
) : null}
<div className="mt-8 flex flex-col gap-3">
<div className="mt-10 flex flex-col items-center gap-3">
<Button
className="w-full"
className="h-10 rounded-full px-6"
disabled={isPending}
onClick={() => void finalize()}
>
@@ -215,6 +321,7 @@ export function CommunityOnboardingFlow({
</Button>
{transaction.error ? (
<Button
className="h-9 rounded-full bg-foreground/10 px-5 hover:bg-foreground/15"
disabled={isPending}
onClick={() => void finish()}
variant="ghost"
@@ -0,0 +1,177 @@
import * as React from "react";
import { useAcpRuntimesQuery } from "@/features/agents/hooks";
import {
GlobalAgentConfigFields,
EMPTY_GLOBAL_CONFIG,
} from "@/features/agents/ui/GlobalAgentConfigFields";
import { createSaveCoalescer } from "./saveCoalescer";
import { getBakedBuildEnv, type BakedEnvEntry } from "@/shared/api/tauri";
import {
getGlobalAgentConfig,
setGlobalAgentConfig,
} from "@/shared/api/tauriGlobalAgentConfig";
import type { GlobalAgentConfig } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
} from "./OnboardingSlideTransition";
import type { DefaultConfigStepActions } from "./types";
import { resolveAgentReadiness } from "./agentReadiness";
type DefaultConfigStepProps = {
actions: DefaultConfigStepActions;
direction: OnboardingTransitionDirection;
};
function AgentDefaultsSection() {
const runtimesQuery = useAcpRuntimesQuery();
const [config, setConfig] =
React.useState<GlobalAgentConfig>(EMPTY_GLOBAL_CONFIG);
const [isLoading, setIsLoading] = React.useState(true);
const [isCustomProvider, setIsCustomProvider] = React.useState(false);
const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false);
const [bakedEnv, setBakedEnv] = React.useState<BakedEnvEntry[]>([]);
const coalescerRef = React.useRef<{
enqueue: (value: GlobalAgentConfig) => void;
cancel: () => void;
} | null>(null);
React.useEffect(() => {
let unmounted = false;
getGlobalAgentConfig()
.then((loaded) => {
if (!unmounted) {
setConfig(loaded);
setIsLoading(false);
}
})
.catch(() => {
if (!unmounted) setIsLoading(false);
});
getBakedBuildEnv()
.then((env) => {
if (!unmounted) setBakedEnv(env);
})
.catch(() => undefined);
// 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,
() => undefined, // saving state not surfaced in this autosave UX
(saved) => {
if (!unmounted) setConfig(saved);
},
);
coalescerRef.current = coalescer;
return () => {
unmounted = true;
coalescer.cancel();
};
}, []);
const buzzAgentRuntime = React.useMemo(
() => (runtimesQuery.data ?? []).find((r) => r.id === "buzz-agent"),
[runtimesQuery.data],
);
const readiness = resolveAgentReadiness(runtimesQuery.data ?? [], config);
return (
<section className="w-full space-y-4 text-left">
{isLoading ? (
<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>
) : (
<div className="rounded-2xl bg-white/85 p-2 shadow-[0_0_55px_25px_rgba(255,255,255,0.6)]">
<GlobalAgentConfigFields
bakedEnv={bakedEnv}
buzzAgentRuntime={buzzAgentRuntime}
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}
/>
</div>
)}
{!readiness.ready ? (
<p className="text-center text-sm text-muted-foreground">
You can finish now and configure agents later in Settings.
</p>
) : null}
</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,
}: DefaultConfigStepProps) {
return (
<OnboardingSlideTransition
className="flex w-full flex-col items-center"
data-testid="onboarding-page-config"
direction={direction}
transitionKey={`default-config-${direction}`}
>
<div className="w-full max-w-[500px] text-center">
<h1 className="text-3xl font-semibold tracking-tight text-foreground">
Configure your default model settings
</h1>
<p className="mt-3 text-sm leading-6 text-foreground/80">
This will be set as your default model configuration across Buzz. You
can always change this in your Settings.
</p>
</div>
<div className="mt-8 w-full max-w-[560px]">
<AgentDefaultsSection />
</div>
<div className="mt-10 flex flex-col items-center gap-3">
<Button
className="h-10 rounded-full px-8"
data-testid="onboarding-finish"
onClick={actions.complete}
type="button"
>
Next
</Button>
<Button
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
data-testid="onboarding-back"
onClick={actions.back}
type="button"
variant="ghost"
>
Back
</Button>
</div>
</OnboardingSlideTransition>
);
}
@@ -9,13 +9,14 @@ import {
import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { BackupStep } from "./BackupStep";
import { DefaultConfigStep } from "./DefaultConfigStep";
import { LandingBees } from "./LandingBees";
import { NostrKeyImportForm } from "./NostrKeyImportForm";
import { OnboardingSlideTransition } from "./OnboardingSlideTransition";
import { OnboardingStepDots } from "./OnboardingStepDots";
import { SetupStep } from "./SetupStep";
type MachinePage = "identity" | "key-import" | "backup" | "setup";
type MachinePage = "identity" | "key-import" | "backup" | "setup" | "config";
export function MachineOnboardingFlow({
complete,
@@ -89,14 +90,16 @@ export function MachineOnboardingFlow({
return (
<div
className={`buzz-onboarding-neutral-theme buzz-startup-shell flex max-h-dvh items-start justify-center overflow-y-auto px-4 text-foreground ${
page === "setup" ? "py-24" : "py-8"
page === "setup" || page === "config" ? "py-24" : "py-8"
} ${page === "identity" ? "buzz-onboarding-welcome" : ""}`}
data-testid="machine-onboarding-gate"
>
<StartupWindowDragRegion />
{page === "identity" ? <LandingBees /> : null}
{page !== "identity" ? (
<OnboardingStepDots current={page === "setup" ? 3 : 2} />
<OnboardingStepDots
current={page === "config" ? 4 : page === "setup" ? 3 : 2}
/>
) : null}
<div className="relative my-auto flex w-full max-w-[920px] flex-col items-center text-center">
{page === "identity" ? (
@@ -168,11 +171,19 @@ export function MachineOnboardingFlow({
onBack={() => setPage("identity")}
onNext={() => setPage("setup")}
/>
) : (
) : page === "setup" ? (
<SetupStep
actions={{
back: () =>
setPage(identityWasImported ? "key-import" : "backup"),
next: () => setPage("config"),
}}
direction="forward"
/>
) : (
<DefaultConfigStep
actions={{
back: () => setPage("setup"),
complete: () => complete(selectedPubkey ?? undefined),
}}
direction="forward"
@@ -1,10 +1,10 @@
import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
/**
* Positions in the first-launch flow: landing, identity/key, provider setup,
* community choice, community profile, meet the team.
* Positions in the first-launch flow: landing, identity/key, harness setup,
* default config, community choice, community profile, meet the team.
*/
export const TOTAL_ONBOARDING_PAGES = 6;
export const TOTAL_ONBOARDING_PAGES = 7;
/**
* Top-left paging indicator for the first-launch flow. The bee marks the
+126 -247
View File
@@ -4,8 +4,8 @@ import {
AlertTriangle,
Check,
ExternalLink,
Info,
Plus,
RefreshCw,
TerminalSquare,
} from "lucide-react";
@@ -15,32 +15,19 @@ import {
useGitBashPrerequisiteQuery,
} from "@/features/agents/hooks";
import { describeResolvedCommand } from "@/features/agents/ui/agentUi";
import {
GlobalAgentConfigFields,
EMPTY_GLOBAL_CONFIG,
} from "@/features/agents/ui/GlobalAgentConfigFields";
import { createSaveCoalescer } from "./saveCoalescer";
import { getBakedBuildEnv, type BakedEnvEntry } from "@/shared/api/tauri";
import {
getGlobalAgentConfig,
setGlobalAgentConfig,
} from "@/shared/api/tauriGlobalAgentConfig";
import type {
AcpRuntimeCatalogEntry,
GlobalAgentConfig,
} from "@/shared/api/types";
import type { AcpRuntimeCatalogEntry } from "@/shared/api/types";
import { getInstallErrorMessage } from "@/shared/lib/installError";
import { cn } from "@/shared/lib/cn";
import { useTheme } from "@/shared/theme/ThemeProvider";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Spinner } from "@/shared/ui/spinner";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
} from "./OnboardingSlideTransition";
import type { SetupStepActions, SetupStepState } from "./types";
import { resolveAgentReadiness } from "./agentReadiness";
type SetupStepProps = {
actions: SetupStepActions;
@@ -58,150 +45,6 @@ type InstallResultState = {
success: boolean;
};
function AgentDefaultsSection() {
const runtimesQuery = useAcpRuntimesQuery();
const [config, setConfig] =
React.useState<GlobalAgentConfig>(EMPTY_GLOBAL_CONFIG);
const [isLoading, setIsLoading] = React.useState(true);
const [isCustomProvider, setIsCustomProvider] = React.useState(false);
const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false);
const [bakedEnv, setBakedEnv] = React.useState<BakedEnvEntry[]>([]);
const coalescerRef = React.useRef<{
enqueue: (value: GlobalAgentConfig) => void;
cancel: () => void;
} | null>(null);
React.useEffect(() => {
let unmounted = false;
getGlobalAgentConfig()
.then((loaded) => {
if (!unmounted) {
setConfig(loaded);
setIsLoading(false);
}
})
.catch(() => {
if (!unmounted) setIsLoading(false);
});
getBakedBuildEnv()
.then((env) => {
if (!unmounted) setBakedEnv(env);
})
.catch(() => undefined);
// 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,
() => undefined, // saving state not surfaced in this autosave UX
(saved) => {
if (!unmounted) setConfig(saved);
},
);
coalescerRef.current = coalescer;
return () => {
unmounted = true;
coalescer.cancel();
};
}, []);
const buzzAgentRuntime = React.useMemo(
() => (runtimesQuery.data ?? []).find((r) => r.id === "buzz-agent"),
[runtimesQuery.data],
);
const readiness = resolveAgentReadiness(runtimesQuery.data ?? [], config);
return (
<section className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<h2 className="text-xl font-semibold tracking-tight text-foreground">
Agent defaults
</h2>
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">
Configure the LLM provider and credentials that buzz-agent uses, or
connect a CLI harness like Claude or Goose above.
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{readiness.ready ? (
<Badge
className="border border-primary/20 bg-primary/10 text-primary"
data-testid="agent-readiness-badge"
variant="outline"
>
{readiness.reason === "cli"
? `${readiness.runtimeLabel} ready`
: "buzz-agent configured"}
</Badge>
) : (
<Badge
className="border border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400"
data-testid="agent-readiness-badge"
variant="outline"
>
Not configured
</Badge>
)}
<Button
className="h-7 gap-1.5 text-xs text-muted-foreground hover:text-foreground"
data-testid="agent-readiness-recheck"
disabled={runtimesQuery.isFetching}
onClick={() => void runtimesQuery.refetch()}
size="sm"
type="button"
variant="ghost"
>
{runtimesQuery.isFetching ? (
<Spinner className="h-3 w-3 border-[1.5px]" />
) : (
<RefreshCw className="h-3 w-3" />
)}
Re-check
</Button>
</div>
</div>
{isLoading ? (
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<Spinner className="h-4 w-4 border-2" />
Loading
</div>
) : (
<GlobalAgentConfigFields
bakedEnv={bakedEnv}
buzzAgentRuntime={buzzAgentRuntime}
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}
/>
)}
{!readiness.ready ? (
<p className="text-sm text-muted-foreground">
You can finish now and configure agents later in Settings.
</p>
) : null}
</section>
);
}
function useSetupStepState(): SetupStepState {
const runtimesQuery = useAcpRuntimesQuery();
const items = runtimesQuery.data ?? [];
@@ -225,25 +68,24 @@ function RuntimeIcon({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
if (runtime.avatarUrl && !imageFailed) {
return (
<div className="flex h-9 w-9 items-center justify-center rounded-md border border-border/45 bg-background/80">
<img
alt=""
className={cn(
"h-7 w-7 rounded-sm object-contain",
shouldForceForegroundColor &&
(isDark ? "brightness-0 invert" : "brightness-0"),
)}
onError={() => setImageFailed(true)}
src={runtime.avatarUrl}
/>
</div>
<img
alt=""
className={cn(
"h-12 w-12 rounded-md object-contain",
shouldForceForegroundColor &&
(isDark ? "brightness-0 invert" : "brightness-0"),
)}
onError={() => setImageFailed(true)}
src={runtime.avatarUrl}
/>
);
}
return (
<div className="flex h-9 w-9 items-center justify-center rounded-md border border-border/45 bg-background/80 text-muted-foreground">
<TerminalSquare className="h-4 w-4" />
</div>
<TerminalSquare
className="h-12 w-12 text-muted-foreground"
strokeWidth={1.25}
/>
);
}
@@ -264,7 +106,7 @@ function RuntimeStatus({
return (
<div
aria-label={`Installing ${runtime.label}`}
className="flex h-8 shrink-0 items-center justify-center"
className="flex h-8 w-8 items-center justify-center"
role="status"
>
<Spinner className="h-4 w-4 border-2 text-foreground" />
@@ -274,7 +116,7 @@ function RuntimeStatus({
if (installError) {
return (
<div className="flex h-8 shrink-0 items-center justify-center">
<div className="flex h-8 w-8 items-center justify-center">
<AlertTriangle className="h-4 w-4 text-destructive" />
</div>
);
@@ -282,8 +124,15 @@ function RuntimeStatus({
if (runtime.availability === "available" || installSuccess) {
return (
<div className="flex h-8 shrink-0 items-center justify-center">
<Check className="h-4 w-4 text-primary" />
<div
aria-label={`${runtime.label} available`}
className="flex h-6 w-6 items-center justify-center rounded-full bg-primary shadow-sm"
role="img"
>
<Check
className="h-3.5 w-3.5 text-primary-foreground"
strokeWidth={3}
/>
</div>
);
}
@@ -292,7 +141,7 @@ function RuntimeStatus({
return (
<Button
aria-label={`Install ${runtime.label}`}
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
data-testid={`onboarding-runtime-install-${runtime.id}`}
onClick={onInstall}
size="icon"
@@ -307,7 +156,7 @@ function RuntimeStatus({
return (
<Button
aria-label={`View ${runtime.label} setup instructions`}
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
data-testid={`onboarding-runtime-instructions-${runtime.id}`}
onClick={() => void openUrl(runtime.installInstructionsUrl)}
size="icon"
@@ -331,7 +180,7 @@ function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
);
return (
<>
<p className="mt-2 text-sm leading-5 text-muted-foreground">
<p className="text-sm leading-5 text-muted-foreground">
{description.charAt(0).toUpperCase() + description.slice(1)}
</p>
{runtime.defaultArgs.length > 0 ? (
@@ -347,7 +196,7 @@ function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
if (runtime.availability === "adapter_missing") {
return (
<>
<p className="mt-2 text-sm leading-5 text-muted-foreground">
<p className="text-sm leading-5 text-muted-foreground">
CLI detected; ACP adapter missing.
</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground/80">
@@ -360,7 +209,7 @@ function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
if (runtime.availability === "adapter_outdated") {
return (
<>
<p className="mt-2 text-sm leading-5 text-muted-foreground">
<p className="text-sm leading-5 text-muted-foreground">
ACP adapter detected but outdated reinstall required.
</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground/80">
@@ -383,7 +232,7 @@ function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
if (runtime.availability === "cli_missing") {
return (
<>
<p className="mt-2 text-sm leading-5 text-muted-foreground">
<p className="text-sm leading-5 text-muted-foreground">
ACP adapter detected; CLI missing.
</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground/80">
@@ -395,7 +244,7 @@ function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
return (
<>
<p className="mt-2 text-sm leading-5 text-muted-foreground">
<p className="text-sm leading-5 text-muted-foreground">
Not installed yet.
</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground/80">
@@ -405,6 +254,30 @@ function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
);
}
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") {
return "ACP adapter detected; CLI missing.";
}
return "Not installed yet.";
}
function RuntimeCard({
installError,
installSuccess,
@@ -423,54 +296,64 @@ function RuntimeCard({
return (
<div
className={cn(
"grid min-h-28 grid-cols-[auto_1fr_auto] items-start gap-3 rounded-lg border bg-background p-3 text-left transition-colors sm:p-4",
"relative flex min-h-40 w-40 flex-col items-center justify-center gap-3 rounded-2xl bg-white/85 p-4 text-center",
isAvailable
? "border-primary/25 bg-primary/[0.055] shadow-[0_12px_30px_hsl(var(--primary)/0.08)] dark:bg-primary/[0.08]"
: installError
? "border-destructive/45 bg-destructive/5 shadow-xs"
: "border-2 border-dashed border-muted-foreground/35 bg-muted/20 shadow-none",
? "shadow-[0_0_55px_25px_rgba(255,255,255,0.85)]"
: "shadow-[0_0_45px_18px_rgba(255,255,255,0.55)] opacity-90",
installError && "ring-1 ring-destructive/40",
)}
data-testid={`onboarding-runtime-${runtime.id}`}
>
<div className="absolute right-2 top-2">
<RuntimeStatus
installError={installError}
installSuccess={installSuccess}
isInstalling={isInstalling}
onInstall={onInstall}
runtime={runtime}
/>
</div>
<div className="absolute left-2 top-2">
<Popover>
<PopoverTrigger asChild>
<Button
aria-label={`${runtime.label} details`}
className="h-6 w-6 text-muted-foreground/70 hover:text-foreground"
data-testid={`onboarding-runtime-details-${runtime.id}`}
size="icon"
type="button"
variant="ghost"
>
<Info className="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80 text-left">
<RuntimeDetails runtime={runtime} />
</PopoverContent>
</Popover>
</div>
<RuntimeIcon runtime={runtime} />
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-medium leading-6 text-foreground">
{runtime.label}
</h2>
{isAvailable ? (
<Badge
className="border border-primary/20 bg-primary/10 text-primary"
variant="outline"
>
Installed
</Badge>
) : null}
</div>
<RuntimeDetails runtime={runtime} />
<h2 className="text-sm font-medium leading-5 text-foreground">
{runtime.label}
</h2>
{!isAvailable && !installError ? (
<p className="mt-1 text-2xs leading-4 text-muted-foreground">
{runtimeDetailText(runtime)}
</p>
) : null}
{installError ? (
<p className="mt-3 whitespace-pre-line rounded-md border border-destructive/25 bg-destructive/10 px-3 py-2 text-xs leading-5 text-destructive">
<p className="mt-1 text-2xs leading-4 text-destructive">
{installError}
</p>
) : null}
{installSuccess && runtime.availability !== "available" ? (
<p className="mt-3 rounded-md border border-primary/25 bg-primary/10 px-3 py-2 text-xs leading-5 text-primary">
Installed successfully. You can finish onboarding now.
</p>
<p className="mt-1 text-2xs leading-4 text-primary">Installed</p>
) : null}
</div>
<RuntimeStatus
installError={installError}
installSuccess={installSuccess}
isInstalling={isInstalling}
onInstall={onInstall}
runtime={runtime}
/>
</div>
);
}
@@ -483,10 +366,10 @@ function GitBashPrerequisiteCard() {
return (
<div
className={cn(
"rounded-lg border p-3 text-left sm:p-4",
"mx-auto w-full max-w-[560px] rounded-2xl bg-white/85 p-3 text-left sm:p-4",
prerequisite.available
? "border-primary/25 bg-primary/[0.055]"
: "border-amber-500/30 bg-amber-500/5",
? "shadow-[0_0_45px_18px_rgba(255,255,255,0.7)]"
: "ring-1 ring-amber-500/40 shadow-[0_0_45px_18px_rgba(255,255,255,0.55)]",
)}
data-testid="onboarding-git-bash"
>
@@ -572,23 +455,21 @@ function RuntimeProvidersSection({
}
return (
<section className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<h1 className="text-3xl font-semibold tracking-tight text-foreground">
Agent harnesses
</h1>
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">
Buzz can launch local ACP-compatible agent harnesses. Install or
verify the runtimes this desktop app can see.
</p>
</div>
<section className="flex w-full flex-col items-center gap-8">
<div className="w-full max-w-[520px] text-center">
<h1 className="text-3xl font-semibold tracking-tight text-foreground">
Use the models that fit the task
</h1>
<p className="mt-3 text-sm leading-6 text-foreground/80">
These are the local agent harnesses Buzz detected. You choose a
harness when creating each agent.
</p>
</div>
<GitBashPrerequisiteCard />
{items.length > 0 ? (
<div className="grid gap-3 lg:grid-cols-2">
<div className="flex flex-wrap items-stretch justify-center gap-4">
{items.map((runtime) => (
<RuntimeCard
installError={installResults[runtime.id]?.error ?? null}
@@ -604,12 +485,12 @@ function RuntimeProvidersSection({
))}
</div>
) : isChecking ? (
<div className="rounded-lg border border-border/70 bg-background px-4 py-6 text-sm text-muted-foreground">
<div className="rounded-2xl bg-white/70 px-6 py-6 text-sm text-muted-foreground">
Looking for compatible runtimes...
</div>
) : errorMessage ? null : (
<p
className="rounded-lg border border-border/70 bg-background px-4 py-6 text-sm text-muted-foreground"
className="max-w-[560px] rounded-2xl bg-white/70 px-6 py-6 text-sm text-muted-foreground"
data-testid="onboarding-acp-empty"
>
No compatible ACP runtimes detected yet. You can finish setup now and
@@ -618,7 +499,7 @@ function RuntimeProvidersSection({
)}
{errorMessage ? (
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<p className="max-w-[560px] rounded-2xl bg-destructive/10 px-6 py-3 text-sm text-destructive">
{errorMessage}
</p>
) : null}
@@ -635,27 +516,25 @@ function SetupStepContent({
return (
<OnboardingSlideTransition
className="space-y-7 text-left"
className="flex w-full flex-col items-center"
data-testid="onboarding-page-2"
direction={direction}
transitionKey={`setup-${direction}`}
>
<RuntimeProvidersSection runtimeProviders={runtimeProviders} />
<AgentDefaultsSection />
<div className="mx-auto flex w-full max-w-md flex-col gap-3">
<div className="mt-10 flex flex-col items-center gap-3">
<Button
className="h-10 w-full"
data-testid="onboarding-finish"
onClick={actions.complete}
className="h-10 rounded-full px-8"
data-testid="onboarding-setup-next"
onClick={actions.next}
type="button"
>
Finish
Next
</Button>
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground"
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
data-testid="onboarding-back"
onClick={actions.back}
type="button"
@@ -57,6 +57,11 @@ export type ProfileStepActions = {
};
export type SetupStepActions = {
back: () => void;
next: () => void;
};
export type DefaultConfigStepActions = {
back: () => void;
complete: () => void;
};
@@ -5,7 +5,7 @@ import { passThroughBackupStep } from "../helpers/onboarding";
const SHOTS = "test-results/screenshots-onboarding";
/** Drive to the setup page (page 2) via the full onboarding flow. */
/** Drive to the harness setup page (page 3) via the full onboarding flow. */
async function navigateToSetupPage(
page: Parameters<typeof installMockBridge>[0],
) {
@@ -14,29 +14,36 @@ async function navigateToSetupPage(
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
}
test("setup page shows Agent defaults section with readiness badge", async ({
page,
}) => {
/** Drive to the default config page (page 4), past the harness page. */
async function navigateToConfigPage(
page: Parameters<typeof installMockBridge>[0],
) {
await navigateToSetupPage(page);
await page.getByTestId("onboarding-setup-next").click();
await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
}
test("config page shows Agent defaults form", async ({ page }) => {
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
await navigateToSetupPage(page);
await navigateToConfigPage(page);
const badge = page.getByTestId("agent-readiness-badge");
await expect(badge).toBeVisible();
// The defaults form is the page's content; no readiness badge is shown.
await expect(page.locator("#global-agent-provider")).toBeVisible();
await expect(page.getByTestId("agent-readiness-badge")).toHaveCount(0);
// Take a screenshot of the entire setup page to capture the readiness badge.
await waitForAnimations(page);
const setupPage = page.locator('[data-testid="onboarding-page-2"]');
await setupPage.screenshot({
path: `${SHOTS}/04-setup-readiness-badge.png`,
const configPage = page.locator('[data-testid="onboarding-page-config"]');
await configPage.screenshot({
path: `${SHOTS}/04-config-defaults-form.png`,
});
});
test("setup page shows Not configured badge when no CLI runtime or buzz-agent config", async ({
test("config page shows configure-later hint when no CLI runtime or buzz-agent config", async ({
page,
}) => {
// Seed empty ACP runtimes so no CLI harness is available.
@@ -47,46 +54,22 @@ test("setup page shows Not configured badge when no CLI runtime or buzz-agent co
);
await page.goto("/");
await navigateToSetupPage(page);
await navigateToConfigPage(page);
const badge = page.getByTestId("agent-readiness-badge");
await expect(badge).toBeVisible();
await expect(badge).toContainText("Not configured");
// Not-configured warning text should be visible.
// Not-configured hint text should be visible below the form.
await expect(
page.getByText("You can finish now and configure agents later in Settings"),
).toBeVisible();
// Take a screenshot showing the not-configured state.
await waitForAnimations(page);
const setupPage = page.locator('[data-testid="onboarding-page-2"]');
await setupPage.screenshot({
const configPage = page.locator('[data-testid="onboarding-page-config"]');
await configPage.screenshot({
path: `${SHOTS}/05-setup-not-configured.png`,
});
});
test("setup page Re-check button triggers runtimes refetch", async ({
page,
}) => {
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
await navigateToSetupPage(page);
const recheckBtn = page.getByTestId("agent-readiness-recheck");
await expect(recheckBtn).toBeVisible();
await expect(recheckBtn).toBeEnabled();
await recheckBtn.click();
// After click the button should still be there (page stays on setup).
await expect(recheckBtn).toBeVisible();
});
test("Finish button is always enabled on setup page regardless of readiness", async ({
test("Finish button is always enabled on config page regardless of readiness", async ({
page,
}) => {
await installMockBridge(
@@ -96,7 +79,7 @@ test("Finish button is always enabled on setup page regardless of readiness", as
);
await page.goto("/");
await navigateToSetupPage(page);
await navigateToConfigPage(page);
const finishBtn = page.getByTestId("onboarding-finish");
await expect(finishBtn).toBeVisible();
@@ -107,6 +90,52 @@ test("Finish button is always enabled on setup page regardless of readiness", as
// B1 regression: rapid consecutive edits must not lose the later change
// ---------------------------------------------------------------------------
test("provider credentials are first-class and drive model discovery", async ({
page,
}) => {
await installMockBridge(
page,
{ acpRuntimesCatalog: undefined },
{ skipCommunitySeed: true, skipOnboardingSeed: true },
);
await page.goto("/");
await navigateToConfigPage(page);
await page.locator("#global-agent-provider").selectOption("openai");
const apiKey = page.getByLabel("OpenAI API Key");
await expect(apiKey).toBeVisible();
await apiKey.fill("test-openai-key");
await expect(
page
.locator("#global-agent-model")
.getByRole("option", { name: "GPT-5.5" }),
).toBeAttached();
await page.locator("#global-agent-provider").selectOption("openai-compat");
await expect(page.getByLabel("OpenAI API Key")).toHaveValue(
"test-openai-key",
);
await page
.locator("#global-agent-provider")
.selectOption("__custom_provider__");
await expect(page.getByLabel("OpenAI API Key")).not.toBeVisible();
await expect(page.locator('input[value="test-openai-key"]')).toHaveCount(0);
await page.locator("#global-agent-provider").selectOption("anthropic");
await expect(page.getByLabel("Anthropic API Key")).toBeVisible();
const databricksOption = page
.locator("#global-agent-provider")
.locator('option[value^="databricks"]')
.first();
await page
.locator("#global-agent-provider")
.selectOption(await databricksOption.getAttribute("value"));
await expect(page.getByLabel("Value for DATABRICKS_HOST")).toBeVisible();
await expect(page.getByLabel("OpenAI API Key")).not.toBeVisible();
});
test("rapid consecutive provider changes both survive — later change wins", async ({
page,
}) => {
@@ -119,7 +148,7 @@ test("rapid consecutive provider changes both survive — later change wins", as
);
await page.goto("/");
await navigateToSetupPage(page);
await navigateToConfigPage(page);
const providerSelect = page.locator("#global-agent-provider");
await expect(providerSelect).toBeVisible();