mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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. They’ll 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. They’ll 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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user