From b0a484f052f72441ff872784c0d507cbbfcc9539 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Wed, 9 Sep 2026 17:58:14 +0100 Subject: [PATCH 01/11] feat(desktop): refresh onboarding flow Signed-off-by: kenny lopez --- desktop/src-tauri/tauri.conf.json | 8 +- .../features/agents/ui/AgentConfigFields.tsx | 99 +- .../agents/ui/PersonaProviderApiKeyField.tsx | 24 +- .../src/features/onboarding/ui/AvatarStep.tsx | 14 +- .../src/features/onboarding/ui/BackupStep.tsx | 264 +++--- .../features/onboarding/ui/BackupTestFlow.tsx | 29 +- .../onboarding/ui/CommunityOnboardingFlow.tsx | 70 +- .../onboarding/ui/ConnectionMethodSection.tsx | 80 ++ .../onboarding/ui/DefaultConfigStep.tsx | 123 ++- .../onboarding/ui/DownloadKeyStep.tsx | 61 +- .../onboarding/ui/EncryptedBackupCreator.tsx | 7 +- .../onboarding/ui/IdentityKeyHelpDialog.tsx | 118 ++- .../onboarding/ui/IdentityKeyIntroduction.tsx | 88 ++ .../onboarding/ui/IdentityRecoveryPairing.tsx | 10 +- .../onboarding/ui/MachineOnboardingFlow.tsx | 526 ++++++----- .../onboarding/ui/NostrKeyImportForm.tsx | 102 ++- .../features/onboarding/ui/OnboardingCard.tsx | 76 ++ .../features/onboarding/ui/OnboardingFlow.tsx | 247 +++-- .../onboarding/ui/OnboardingFooter.tsx | 63 +- .../onboarding/ui/OnboardingInput.tsx | 69 ++ .../features/onboarding/ui/ProfileStep.tsx | 91 +- .../ui/SetupStep.acpForcedGate.test.mjs | 191 +--- .../src/features/onboarding/ui/SetupStep.tsx | 844 +++++++++++++----- .../ui/harnessConnectionOptions.test.mjs | 38 + .../onboarding/ui/harnessConnectionOptions.ts | 40 + .../onboarding/ui/onboardingCardStyles.ts | 7 + .../ui/onboardingRuntimeSelection.test.mjs | 4 +- .../ui/onboardingRuntimeSelection.ts | 9 + desktop/src/features/onboarding/ui/types.ts | 6 +- .../src/shared/styles/globals/components.css | 4 + desktop/tailwind.config.js | 5 + desktop/tests/e2e/harness-management.spec.ts | 7 +- desktop/tests/e2e/identity-lost.spec.ts | 43 +- .../e2e/onboarding-agent-defaults.spec.ts | 702 +++++++-------- desktop/tests/e2e/onboarding-backup.spec.ts | 167 ++-- .../onboarding-docked-cta-screenshots.spec.ts | 588 ++++++++++-- desktop/tests/e2e/onboarding.spec.ts | 51 +- 37 files changed, 3264 insertions(+), 1611 deletions(-) create mode 100644 desktop/src/features/onboarding/ui/ConnectionMethodSection.tsx create mode 100644 desktop/src/features/onboarding/ui/IdentityKeyIntroduction.tsx create mode 100644 desktop/src/features/onboarding/ui/OnboardingCard.tsx create mode 100644 desktop/src/features/onboarding/ui/OnboardingInput.tsx create mode 100644 desktop/src/features/onboarding/ui/harnessConnectionOptions.test.mjs create mode 100644 desktop/src/features/onboarding/ui/harnessConnectionOptions.ts create mode 100644 desktop/src/features/onboarding/ui/onboardingCardStyles.ts diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 71c5fc2e049..9231d002613 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -17,8 +17,8 @@ "windows": [ { "title": "", - "width": 800, - "height": 600, + "width": 900, + "height": 650, "maximized": true, "visible": false, "transparent": false, @@ -30,8 +30,8 @@ "y": 25 }, "backgroundThrottling": "disabled", - "minWidth": 800, - "minHeight": 500 + "minWidth": 900, + "minHeight": 650 } ], "macOSPrivateApi": true, diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 5e0a4ab9613..e640e84ec12 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -184,6 +184,8 @@ export type AgentConfigFieldsProps = { runtimeFileConfig?: RuntimeFileConfigSubset | null; placeholderClassName?: string; selectClassName?: string; + showApiKeyEnvVarName?: boolean; + stackModelAndEffortHorizontally?: boolean; /** * Which disclosure preset to render (PR 2 flag cleanup — replaces eight * independent show* booleans): @@ -220,6 +222,8 @@ export function AgentConfigFields({ runtimeFileConfig, placeholderClassName, selectClassName, + showApiKeyEnvVarName = true, + stackModelAndEffortHorizontally = false, disclosure = "full", unstyled = false, useCustomSelect = false, @@ -641,6 +645,27 @@ export function AgentConfigFields({ ? (config.env_vars[effortPersistenceKey] ?? "") : ""; const effortFieldVisible = showEffortField && effortField !== undefined; + const apiKeyCredentialPresent = + apiKeyValue.trim().length > 0 || apiKeyInherited; + const apiKeyValidationRequired = + stackModelAndEffortHorizontally && apiKeyEnvVar !== null; + const apiKeyValidationPending = + apiKeyValidationRequired && + apiKeyCredentialPresent && + modelDiscoveryLoading; + const apiKeyValidationSucceeded = + !apiKeyValidationRequired || + (apiKeyCredentialPresent && + !modelDiscoveryLoading && + discoveredModelOptions !== null); + const apiKeyValidationFailed = + apiKeyValidationRequired && + apiKeyCredentialPresent && + !modelDiscoveryLoading && + discoveredModelOptions === null && + modelDiscoveryStatus !== null; + const onboardingModelAndEffortVisible = + configuredProviderValue.trim().length > 0 && apiKeyValidationSucceeded; const progressiveDefaults = disclosure === "progressive-defaults"; const fieldClassName = unstyled @@ -650,7 +675,9 @@ export function AgentConfigFields({ : "space-y-1.5 p-3"; const blockClassName = unstyled ? "" : "p-3"; const fieldLabelClassName = - unstyled && !progressiveDefaults ? "pl-3" : undefined; + unstyled && !progressiveDefaults && !stackModelAndEffortHorizontally + ? "pl-3" + : undefined; const providerDropdownOptions = [ ...providerOptions .filter( @@ -770,32 +797,8 @@ export function AgentConfigFields({ ); - const dependentContent = ( + const modelAndEffortFields = ( <> - {providerFieldVisible && apiKeyEnvVar ? ( -
- - onConfigChange({ - ...config, - env_vars: { ...config.env_vars, [apiKeyEnvVar]: value }, - }) - } - value={apiKeyValue} - /> -
- ) : null} - {/* Model field — omitted only after confirmed successful empty discovery */} {modelControlVisible ? (
@@ -893,6 +896,50 @@ export function AgentConfigFields({ />
) : null} + + ); + + const dependentContent = ( + <> + {providerFieldVisible && apiKeyEnvVar ? ( +
+ + onConfigChange({ + ...config, + env_vars: { ...config.env_vars, [apiKeyEnvVar]: value }, + }) + } + validationMessage={ + apiKeyValidationFailed + ? "We couldn’t validate this API key. Check the key or your connection and try again." + : null + } + value={apiKeyValue} + /> +
+ ) : null} + + {!stackModelAndEffortHorizontally || onboardingModelAndEffortVisible ? ( + stackModelAndEffortHorizontally ? ( +
+ {modelAndEffortFields} +
+ ) : ( + modelAndEffortFields + ) + ) : null} {showAdvancedFields ? (
diff --git a/desktop/src/features/agents/ui/PersonaProviderApiKeyField.tsx b/desktop/src/features/agents/ui/PersonaProviderApiKeyField.tsx index 2be1f1c28d8..a34f81f9550 100644 --- a/desktop/src/features/agents/ui/PersonaProviderApiKeyField.tsx +++ b/desktop/src/features/agents/ui/PersonaProviderApiKeyField.tsx @@ -29,8 +29,10 @@ export function PersonaProviderApiKeyField({ isInherited, inheritedLabel, isRequired, + isValidating = false, label, onValueChange, + validationMessage, value, }: { disabled: boolean; @@ -47,9 +49,13 @@ export function PersonaProviderApiKeyField({ inheritedLabel: string; /** True when the key is required and not satisfied anywhere. */ isRequired: boolean; + /** True while the provider is checking the current key. */ + isValidating?: boolean; /** Display label, e.g. "Anthropic API Key". */ label: string; onValueChange: (next: string) => void; + /** User-facing validation error for the current key. */ + validationMessage?: string | null; /** Current agent-local value of the secret env var. */ value: string; }) { @@ -59,6 +65,12 @@ export function PersonaProviderApiKeyField({ const hintId = envVarName ? `persona-provider-api-key-hint-${uid}` : undefined; + const validationId = + isValidating || validationMessage + ? `persona-provider-api-key-validation-${uid}` + : undefined; + const describedBy = + [hintId, validationId].filter(Boolean).join(" ") || undefined; return (
@@ -77,7 +89,8 @@ export function PersonaProviderApiKeyField({ )} >
+ {isValidating ? ( +

+ Checking API key… +

+ ) : validationMessage ? ( + + ) : null}
); } diff --git a/desktop/src/features/onboarding/ui/AvatarStep.tsx b/desktop/src/features/onboarding/ui/AvatarStep.tsx index 177f8dcaa2f..04156dc1cc5 100644 --- a/desktop/src/features/onboarding/ui/AvatarStep.tsx +++ b/desktop/src/features/onboarding/ui/AvatarStep.tsx @@ -9,6 +9,7 @@ import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Spinner } from "@/shared/ui/spinner"; import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome"; +import { useOnboardingCardLayout } from "./OnboardingCard"; import { OnboardingFooter } from "./OnboardingFooter"; import { AnimatePresence, motion } from "motion/react"; import * as React from "react"; @@ -284,6 +285,7 @@ export function AvatarStep({ isCustomColorPickerOpen || shouldHideActionsForAnimatedAvatar; const previewName = name.draftValue.trim() || name.savedValue.trim() || "Your avatar"; + const cardLayout = useOnboardingCardLayout(); const animateEmojiAvatarChange = React.useCallback(() => { setAvatarSquishKey((key) => key + 1); }, []); @@ -322,13 +324,21 @@ export function AvatarStep({ // onboarding content and overflows on short windows, so the shell's own // bottom reserve isn't enough to scroll the last rows out from under the // fixed CTA group + scrim. - className="flex w-full flex-col items-center pb-20" + className={cn( + "flex w-full flex-col", + cardLayout ? "min-h-0 items-stretch pb-0" : "items-center pb-20", + )} data-testid="onboarding-page-avatar" direction={direction} transitionKey={`avatar-${direction}`} > void; onOpenPasswordBackup: () => void; - onShowOptions: () => void; optionsExpanded: boolean; returningFromSecurity: boolean; }; /** * Onboarding identity-key step — shows the freshly created key, then opens a - * dark backup-options state. Copy fetches the raw key only after an explicit - * click; password backup opens the separate security flow. Neither method - * blocks Next. + * dark backup-options state. Hovering obscures the visible key and presents an + * explicit copy action; password backup opens the separate security flow. + * Neither method blocks Next. */ export function BackupStep({ direction, identityStorage, onNext, onOpenPasswordBackup, - onShowOptions, optionsExpanded, returningFromSecurity, }: BackupStepProps) { const reduceMotion = useReducedMotion() ?? false; + const cardLayout = useOnboardingCardLayout(); const [created, setCreated] = React.useState(introPlayed || reduceMotion); const [copyState, setCopyState] = React.useState< "idle" | "copying" | "copied" >("idle"); const [copyError, setCopyError] = React.useState(null); const [nsec, setNsec] = React.useState(null); - const [isRevealed, setIsRevealed] = React.useState(false); const cancelledRef = React.useRef(false); const copiedTimerRef = React.useRef(null); @@ -99,20 +104,34 @@ export function BackupStep({ React.useEffect(() => { cancelledRef.current = false; return () => { - // Back-during-fetch: cancel any in-flight setState calls and clear the - // nsec from memory on unmount (backup step is only on the fresh-key path). + // Back-during-fetch: cancel any in-flight setState calls. cancelledRef.current = true; - setNsec(null); if (copiedTimerRef.current !== null) window.clearTimeout(copiedTimerRef.current); }; }, []); + React.useEffect(() => { + void getNsec() + .then((value) => { + if (!cancelledRef.current) setNsec(value); + }) + .catch((err: unknown) => { + if (cancelledRef.current) return; + setCopyError( + err instanceof Error + ? err.message + : "Failed to retrieve private key.", + ); + }); + }, []); + const copyKeyToClipboard = React.useCallback(async () => { setCopyState("copying"); setCopyError(null); try { const value = nsec ?? (await getNsec()); + if (!nsec && !cancelledRef.current) setNsec(value); await writeTextToClipboard(value); if (cancelledRef.current) return; setCopyState("copied"); @@ -129,35 +148,6 @@ export function BackupStep({ ); } }, [nsec]); - - const toggleReveal = React.useCallback(async () => { - if (isRevealed) { - setIsRevealed(false); - return; - } - setCopyError(null); - try { - // The raw key enters the DOM only after this explicit reveal action. - const value = nsec ?? (await getNsec()); - if (cancelledRef.current) return; - setNsec(value); - setIsRevealed(true); - } catch (err) { - if (cancelledRef.current) return; - setCopyError( - err instanceof Error ? err.message : "Failed to retrieve private key.", - ); - } - }, [isRevealed, nsec]); - - // Fixed-length decorative mask (nsec keys are 63 chars) so no key material - // is fetched just to render the blurred row. Bullets are joined with a - // zero-width space: WebKit won't line-break a run of U+2022 without an - // explicit break opportunity, so the masked row would overflow otherwise. - const maskedKey = React.useMemo( - () => Array.from({ length: nsec?.length ?? 63 }, () => "•").join("\u200b"), - [nsec], - ); const storageDescription = identityStorage === "system-keyring" ? "Buzz keeps your identity key in your system keychain. Your computer may ask for your password when Buzz needs to read the key." @@ -170,39 +160,58 @@ export function BackupStep({ : identityStorage === "local-file" ? "Stored in private device storage" : "Protected in private device storage"; - const introStorageDescription = - identityStorage === "system-keyring" - ? "Buzz keeps your identity key in your system keychain." - : identityStorage === "local-file" - ? "Buzz keeps your identity key in a private file on this device because the system keychain wasn’t available." - : "Your identity key is protected on this device."; - if (optionsExpanded) { return ( -
+

Backup options

-

+

Your identity key works like a password for your Buzz account. Keep a copy somewhere safe. You can create a backup file and lock it with a password you can remember.

-
+
{storageTitle} @@ -212,7 +221,11 @@ export function BackupStep({
@@ -249,7 +262,11 @@ export function BackupStep({
@@ -291,39 +308,38 @@ export function BackupStep({ return ( -
+
{/* Plain string concat: cn()'s tailwind-merge misreads the custom text-title size token as conflicting with text-foreground. */}

- {created - ? "Your unique identity key has been created" - : "Creating your identity key"} + {created ? "Your private identity key" : "Creating your identity key"}

{created ? (

- {introStorageDescription} You can continue now, or{" "} - {" "} - for ways to restore your account. + Don’t share this key. Anyone who has it can access your account.

) : null}
@@ -344,49 +360,73 @@ export function BackupStep({ ) : (
- -
-
-

- {isRevealed && nsec ? nsec : maskedKey} -

-
- -
-
+
+

+ {nsec} +

+
+ +
+ + {copyError ? (

Could not retrieve your private key: {copyError}. You can @@ -394,14 +434,6 @@ export function BackupStep({ Identity.

) : null} - -

- - - Never share your private key. Anyone with this key can - impersonate you and access everything in your account. - -

)} @@ -414,7 +446,7 @@ export function BackupStep({ onClick={onNext} type="button" > - Next + I’ve saved my key diff --git a/desktop/src/features/onboarding/ui/BackupTestFlow.tsx b/desktop/src/features/onboarding/ui/BackupTestFlow.tsx index 9370d5c0614..19cac9ed13a 100644 --- a/desktop/src/features/onboarding/ui/BackupTestFlow.tsx +++ b/desktop/src/features/onboarding/ui/BackupTestFlow.tsx @@ -18,6 +18,7 @@ import { ONBOARDING_SECURITY_PRIMARY_CTA_CLASS, ONBOARDING_SECONDARY_CTA_CLASS, } from "./OnboardingChrome"; +import { useOnboardingCardLayout } from "./OnboardingCard"; type BackupTestStage = "drop" | "password" | "success"; @@ -205,7 +206,18 @@ export function BackupTestFlow({ onProgressChange, onVerified, }: BackupTestFlowProps) { + const cardLayout = useOnboardingCardLayout(); const reduceMotion = useReducedMotion() ?? false; + const stageEntrance = reduceMotion + ? false + : cardLayout + ? { opacity: 0 } + : { opacity: 0, y: 10 }; + const successCopyEntrance = reduceMotion + ? false + : cardLayout + ? { opacity: 0 } + : { opacity: 0, y: 8 }; const { stage, fileName, ncryptsec, result } = progress; // True while a file drag is anywhere over the window — the drop overlay // takes over the host surface only for the duration of the drag. @@ -389,8 +401,8 @@ export function BackupTestFlow({