- void handleComplete()}
- type="button"
- >
- {isSaving ? "Saving…" : "Next"}
-
Skip for now
+ void handleComplete()}
+ type="button"
+ >
+ {isSaving ? "Saving…" : "Next"}
+
{saveError ? (
diff --git a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx
index d91bc92e61a..7f5761b5a38 100644
--- a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx
+++ b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx
@@ -1,11 +1,14 @@
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
+import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
+ ONBOARDING_PRIMARY_CTA_CLASS,
ONBOARDING_SECURITY_PRIMARY_CTA_CLASS,
ONBOARDING_SECONDARY_CTA_CLASS,
} from "./OnboardingChrome";
+import { useOnboardingCardLayout } from "./OnboardingCard";
import { OnboardingFooter } from "./OnboardingFooter";
import {
type OnboardingTransitionDirection,
@@ -34,6 +37,7 @@ export function DownloadKeyStep({
onBack,
}: DownloadKeyStepProps) {
const reduceMotion = useReducedMotion() ?? false;
+ const cardLayout = useOnboardingCardLayout();
// Once the encrypted payload is saved, the creator advances to its guided
// backup test while this surface keeps its own navigation.
const hasCreated = session.created;
@@ -41,18 +45,34 @@ export function DownloadKeyStep({
const hasSelectedBackup = session.test.stage === "password";
const [primaryActionSlot, setPrimaryActionSlot] =
React.useState(null);
+ const headingEntrance = reduceMotion
+ ? false
+ : cardLayout
+ ? { opacity: 0 }
+ : { opacity: 0, y: 10 };
+ const panelEntrance = reduceMotion
+ ? false
+ : cardLayout
+ ? { opacity: 0 }
+ : { opacity: 0, y: 12 };
return (
-
+
{hasVerifiedBackup
? "Your file and password can restore your identity."
: hasSelectedBackup
- ? "Now enter your password to prove you can unlock it."
+ ? "Enter your password to make sure you can unlock this file."
: hasCreated
- ? "Learn how your backup works. Drop the file you just saved and unlock it with your password."
- : "Keep the downloaded file private — you need both it and your password to restore your identity. Save the backup password somewhere safe; Buzz cannot reset it if lost."}
+ ? "Test your backup to make sure it works, or continue without testing."
+ : "This creates a password-protected file with your private key. Remember, Buzz can’t recover your key if you lose it."}
-
+
- {hasVerifiedBackup ? "Finish" : "Skip for now"}
+ {hasVerifiedBackup ? "Continue" : "Skip for now"}
) : null}
diff --git a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx
index bb76166bd76..0fc26a7dde4 100644
--- a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx
+++ b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx
@@ -38,6 +38,8 @@ import {
initialBackupTestProgress,
} from "./BackupTestFlow";
import { BackupPasswordTimeline } from "./BackupPasswordTimeline";
+import { useOnboardingCardLayout } from "./OnboardingCard";
+import { OnboardingInput } from "./OnboardingInput";
import {
ONBOARDING_SECURITY_PRIMARY_CTA_CLASS,
ONBOARDING_SECONDARY_CTA_CLASS,
@@ -63,93 +65,6 @@ const DEFAULT_SEPARATOR = SEPARATOR_OPTIONS[0].value;
*/
const ENCRYPT_DEBOUNCE_MS = 400;
-const PENDING_TICKER_MESSAGES = [
- "Downloading once finished",
- "Encrypting your password",
- "Just a bit longer...",
-] as const;
-
-/** How long each ticker message holds before sliding to the next. */
-const PENDING_TICKER_INTERVAL_MS = 2500;
-
-/** Matches the `duration-300` slide transition on the ticker column. */
-const PENDING_TICKER_SLIDE_MS = 300;
-
-/**
- * Vertical ticker for the queued-download button label — cycles through the
- * pending messages by sliding a stacked column inside a one-line viewport.
- * The column ends with a clone of the first message, so the wrap-around
- * slides up from the bottom like every other step; once the clone settles,
- * the column snaps (transition disabled) back to the real first row. All
- * lines render at all times, so the button keeps the width of the longest
- * message instead of resizing on each swap.
- */
-function PendingDownloadTicker() {
- // Index into the rendered column (messages + trailing clone of the first).
- const [position, setPosition] = React.useState(0);
- const [snap, setSnap] = React.useState(false);
-
- React.useEffect(() => {
- const timer = window.setInterval(
- () => setPosition((current) => current + 1),
- PENDING_TICKER_INTERVAL_MS,
- );
- return () => window.clearInterval(timer);
- }, []);
-
- // The clone is visually identical to the first message: once its slide-in
- // finishes, jump back to the real first row without animating.
- React.useEffect(() => {
- if (position !== PENDING_TICKER_MESSAGES.length) return;
- const timer = window.setTimeout(() => {
- setSnap(true);
- setPosition(0);
- }, PENDING_TICKER_SLIDE_MS);
- return () => window.clearTimeout(timer);
- }, [position]);
-
- // Re-enable the transition one frame after the snap has painted.
- React.useEffect(() => {
- if (!snap) return;
- const raf = window.requestAnimationFrame(() => setSnap(false));
- return () => window.cancelAnimationFrame(raf);
- }, [snap]);
-
- // The clone row duplicates the first message's text, so it carries its own
- // stable key.
- const column = [
- ...PENDING_TICKER_MESSAGES.map((message) => ({ key: message, message })),
- { key: "wrap-clone", message: PENDING_TICKER_MESSAGES[0] },
- ];
-
- return (
-
-
- {column.map((row) => (
-
- {row.message}
-
- ))}
-
-
- );
-}
-
/**
* Everything about an in-progress backup that must survive this component
* unmounting: the reducer state (short-lived passphrase + encrypted blob), whether the
@@ -441,7 +356,7 @@ function PassphraseGeneratorPopover({
* opens a 1Password-style generator popover (word count + separator).
* Encryption starts eagerly once the password is valid, so Download usually
* opens the save dialog instantly. Background encryption is silent; clicking
- * mid-encryption reveals the queued-download ticker until the KDF finishes.
+ * mid-encryption replaces the button label with its compact loading state.
*/
export function EncryptedBackupCreator({
variant = "spotlight",
@@ -454,6 +369,7 @@ export function EncryptedBackupCreator({
guidedTest = true,
onVerified,
}: EncryptedBackupCreatorProps) {
+ const cardLayout = useOnboardingCardLayout();
// Hosts without a longer-lived session get a private one (settings card).
const fallbackSession = useEncryptedBackupSession();
const session = sessionProp ?? fallbackSession;
@@ -588,6 +504,7 @@ export function EncryptedBackupCreator({
const issue = passphraseIssue(state.passphrase);
const showBackupTimeline =
variant === "spotlight" &&
+ !cardLayout &&
!state.savedPassword &&
!state.createError &&
!saveError;
@@ -596,7 +513,7 @@ export function EncryptedBackupCreator({
// while the native save dialog is open the password form stays put.
if (state.ncryptsec && savedPath && guidedTest) {
return (
-
+
{showBackupTimeline ?
: null}
-
{
- if (state.savedPassword) {
- event.preventDefault();
- setConfirmNewPassword(true);
+ {cardLayout ? (
+
+ Password
+
+ ) : null}
+
+
{
+ if (state.savedPassword) {
+ event.preventDefault();
+ setConfirmNewPassword(true);
+ }
+ }}
+ onPaste={(event) => {
+ if (state.savedPassword) {
+ event.preventDefault();
+ setConfirmNewPassword(true);
+ }
+ }}
+ onChange={(event) =>
+ dispatch({ type: "set-passphrase", value: event.target.value })
}
- }}
- onPaste={(event) => {
- if (state.savedPassword) {
+ onKeyDown={(event) => {
+ if (event.key !== "Enter" || event.nativeEvent.isComposing)
+ return;
event.preventDefault();
- setConfirmNewPassword(true);
+ if (downloadDisabled(state) || isSaving) return;
+ if (state.savedPassword && state.ncryptsec) {
+ void handleSaveCopy();
+ return;
+ }
+ dispatch({ type: "download-clicked" });
+ }}
+ placeholder={
+ state.savedPassword
+ ? ""
+ : `Password (min ${MIN_PASSPHRASE_LEN} characters)`
}
- }}
- onChange={(event) =>
- dispatch({ type: "set-passphrase", value: event.target.value })
- }
- onKeyDown={(event) => {
- if (event.key !== "Enter" || event.nativeEvent.isComposing)
- return;
- event.preventDefault();
- if (downloadDisabled(state) || isSaving) return;
- if (state.savedPassword && state.ncryptsec) {
- void handleSaveCopy();
- return;
+ type={isRevealed ? "text" : "password"}
+ value={state.passphrase}
+ />
+ {state.savedPassword ? (
+
+ ••••••••••••••••••••••••••••••••
+
+ ) : null}
+ {state.savedPassword ? (
+
+ Backup password saved; hidden for security.
+
+ ) : null}
+
- {state.savedPassword ? (
-
- ••••••••••••••••••••••••••••••••
-
- ) : null}
- {state.savedPassword ? (
-
- Backup password saved; hidden for security.
-
- ) : null}
-
- state.savedPassword
- ? setConfirmNewPassword(true)
- : setIsRevealed((revealed) => !revealed)
- }
- size="icon"
- type="button"
- variant="ghost"
- >
- {isRevealed ? (
-
- ) : (
-
- )}
-
- setConfirmNewPassword(true)
- : undefined
- }
- onGenerated={(value) => {
- dispatch({ type: "set-passphrase", value });
- // A generated password must be visible so the user can save it.
- setIsRevealed(true);
- }}
- securityTheme={variant === "spotlight"}
- />
- {issue ? (
-
+ state.savedPassword
+ ? setConfirmNewPassword(true)
+ : setIsRevealed((revealed) => !revealed)
+ }
+ size="icon"
+ type="button"
+ variant="ghost"
>
- {issue}
-
- ) : null}
+ {isRevealed ? (
+
+ ) : (
+
+ )}
+
+ setConfirmNewPassword(true)
+ : undefined
+ }
+ onGenerated={(value) => {
+ dispatch({ type: "set-passphrase", value });
+ // A generated password must be visible so the user can save it.
+ setIsRevealed(true);
+ }}
+ securityTheme={variant === "spotlight" && !cardLayout}
+ />
+ {issue ? (
+
+ {issue}
+
+ ) : null}
+
@@ -791,17 +726,18 @@ export function EncryptedBackupCreator({
{(() => {
// A queued download gets an explicit progress treatment. Background
// encryption stays silent until the user asks to download.
+ const isCreatePending = state.downloadPending || isSaving;
const createButton = (
- {state.downloadPending || isSaving ? (
-
- ) : null}
@@ -811,12 +747,16 @@ export function EncryptedBackupCreator({
}
type="button"
>
- {state.downloadPending ? (
-
+ {isCreatePending ? (
+
) : state.savedPassword ? (
"Download backup again"
) : (
- "Backup key"
+ "Save backup"
)}
diff --git a/desktop/src/features/onboarding/ui/IdentityKeyHelpDialog.tsx b/desktop/src/features/onboarding/ui/IdentityKeyHelpDialog.tsx
index 0abcd77cb02..54a19884084 100644
--- a/desktop/src/features/onboarding/ui/IdentityKeyHelpDialog.tsx
+++ b/desktop/src/features/onboarding/ui/IdentityKeyHelpDialog.tsx
@@ -1,5 +1,6 @@
import * as React from "react";
+import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
Dialog,
@@ -33,11 +34,19 @@ function rememberIdentityKeyHelpSeen() {
}
}
-export function IdentityKeyHelpDialog() {
- const [isVisible, setIsVisible] = React.useState(hasSeenIdentityKeyHelp);
+export function IdentityKeyHelpDialog({
+ inline = false,
+ onOpen,
+}: {
+ inline?: boolean;
+ onOpen?: () => void;
+}) {
+ const [isVisible, setIsVisible] = React.useState(
+ inline ? true : hasSeenIdentityKeyHelp,
+ );
React.useEffect(() => {
- if (isVisible) return;
+ if (inline || isVisible) return;
const timeout = window.setTimeout(() => {
rememberIdentityKeyHelpSeen();
@@ -45,25 +54,46 @@ export function IdentityKeyHelpDialog() {
}, IDENTITY_KEY_HELP_DELAY_MS);
return () => window.clearTimeout(timeout);
- }, [isVisible]);
+ }, [inline, isVisible]);
- return (
-
+ const triggerButton = (
+
+ {inline ? "Learn how identity keys work" : "What’s an identity key?"}
+
+ );
+
+ if (onOpen) {
+ return inline ? (
+ triggerButton
+ ) : (
-
-
- What’s an identity key?
-
-
+ {triggerButton}
+ );
+ }
+
+ const trigger = {triggerButton} ;
+
+ return (
+
+ {inline ? (
+ trigger
+ ) : (
+ {trigger}
+ )}
-
- Buzz uses an identity key instead of a traditional account. It’s
- created on your device and represents you whenever you use Buzz.
-
-
- Your identity belongs to you, not Buzz. There’s no password to
- reset, and Buzz can’t recover your key if you lose it. Keep a
- backup somewhere safe and never share it. Anyone with your key
- can act as you.
-
-
- If you’re new to Buzz, create a new identity key. If you already
- have a Nostr identity, use your existing key.
-
+
@@ -102,3 +119,38 @@ export function IdentityKeyHelpDialog() {
);
}
+
+function IdentityKeyHelpBody() {
+ return (
+ <>
+
+ Buzz will create a Nostr identity with two parts: a private key that
+ signs you in and a public key you can safely share. You can find your
+ public identity anytime in Buzz settings.
+
+
+ This identity belongs to you, not Buzz, and can move with you to another
+ device or compatible Nostr app. Because only you control the private
+ key, Buzz can’t reset or recover it. Keep a backup somewhere safe, and
+ never share it.
+
+ >
+ );
+}
+
+/** Identity-key explainer content for the onboarding card sheet. */
+export function IdentityKeyHelpContent() {
+ return (
+
+
+ What’s an identity key?
+
+
+
+
+
+ );
+}
diff --git a/desktop/src/features/onboarding/ui/IdentityKeyIntroduction.tsx b/desktop/src/features/onboarding/ui/IdentityKeyIntroduction.tsx
new file mode 100644
index 00000000000..b9a58fd2889
--- /dev/null
+++ b/desktop/src/features/onboarding/ui/IdentityKeyIntroduction.tsx
@@ -0,0 +1,99 @@
+import { CircleSlash2, HardDriveDownload, ShieldCheck } from "lucide-react";
+
+import { Button } from "@/shared/ui/button";
+import { IdentityKeyHelpDialog } from "./IdentityKeyHelpDialog";
+import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
+import { OnboardingFooter } from "./OnboardingFooter";
+import {
+ type OnboardingTransitionDirection,
+ OnboardingSlideTransition,
+} from "./OnboardingSlideTransition";
+import { ONBOARDING_CARD_NEUTRAL_SURFACE_CLASS } from "./onboardingCardStyles";
+
+const GUIDANCE_ICON_CLASS = `flex size-10 shrink-0 items-center justify-center rounded-full ${ONBOARDING_CARD_NEUTRAL_SURFACE_CLASS}`;
+
+export function IdentityKeyIntroduction({
+ direction,
+ disabled,
+ error,
+ onCreate,
+ onOpenHelp,
+}: {
+ direction: OnboardingTransitionDirection;
+ disabled: boolean;
+ error?: string | null;
+ onCreate: () => void;
+ onOpenHelp: () => void;
+}) {
+ return (
+
+
+
+ Create a private identity key
+
+
+ This key will be how you log into Buzz. You can use it across Buzz
+ communities and other platforms.
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ Stored securely on this device
+
+
+
+
+
+
+
+ Never share it—anyone with this key can sign in as you
+
+
+
+
+
+
+
+ Use a secure backup to recover your account
+
+
+
+
+
+
+
+ {disabled ? "Creating key…" : "Create my private key"}
+
+
+
+ );
+}
diff --git a/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx b/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx
index 5cca7c1bf5e..0df4f1f7cc5 100644
--- a/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx
+++ b/desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx
@@ -157,11 +157,11 @@ export function IdentityRecoveryPairing({
return (
{step === "qr" && qrUri ? (
@@ -267,12 +267,6 @@ export function IdentityRecoveryPairing({
{error}
) : null}
- {step === "qr" || step === "loading" ? (
-
- On your phone, open Settings → Send identity to desktop. This code
- expires shortly and works once.
-
- ) : null}
);
}
diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
index 27d6b8ce447..4579f331477 100644
--- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
+++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
@@ -9,12 +9,6 @@ import {
} from "@/shared/api/tauriIdentity";
import type { IdentityStorage } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogTitle,
-} from "@/shared/ui/dialog";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { BackupStep } from "./BackupStep";
import { DefaultConfigStep } from "./DefaultConfigStep";
@@ -24,7 +18,11 @@ import {
resetEncryptedBackupSession,
useEncryptedBackupSession,
} from "./EncryptedBackupCreator";
-import { IdentityKeyHelpDialog } from "./IdentityKeyHelpDialog";
+import {
+ IdentityKeyHelpContent,
+ IdentityKeyHelpDialog,
+} from "./IdentityKeyHelpDialog";
+import { IdentityKeyIntroduction } from "./IdentityKeyIntroduction";
import { IdentityRecoveryPairing } from "./IdentityRecoveryPairing";
import { LandingBees } from "./LandingBees";
import {
@@ -32,33 +30,29 @@ import {
type NostrKeyImportStage,
} from "./NostrKeyImportForm";
import {
- ONBOARDING_INK_ICON_CLASS,
ONBOARDING_LANDING_CTA_CLASS,
ONBOARDING_SECONDARY_CTA_CLASS,
- OnboardingChrome,
} from "./OnboardingChrome";
+import { OnboardingCard } from "./OnboardingCard";
import { OnboardingFooterProvider } from "./OnboardingFooter";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
} from "./OnboardingSlideTransition";
import { SetupStep } from "./SetupStep";
+import type { HarnessConnectionMethod } from "./harnessConnectionOptions";
import type { DefaultConfigDraft } from "./types";
export type MachineOnboardingPage =
| "identity"
+ | "identity-key-intro"
+ | "identity-key-help"
| "key-import"
| "backup"
| "setup"
| "config";
-type BackupSubview = "created" | "options" | "password";
-
-/** A pending navigation the parent should execute after RouterProvider mounts. */
-export type PostOnboardingNavigation = {
- to: string;
- search?: Record
;
-};
+type BackupSubview = "created" | "password";
export function MachineOnboardingFlow({
complete,
@@ -67,21 +61,16 @@ export function MachineOnboardingFlow({
identityLost,
initialPage,
queryClient,
- navigateAfterComplete,
}: {
- complete: (pubkey?: string) => void;
+ complete: (
+ pubkey?: string,
+ options?: { continueToProfile?: boolean },
+ ) => void;
continueWithIdentity: (pubkey: string) => void;
continueWithRecoveredIdentity: (pubkey: string) => void;
identityLost: boolean;
initialPage?: MachineOnboardingPage;
queryClient: QueryClient;
- /**
- * Called when the user finishes onboarding and requests navigation to a
- * specific route (e.g. Settings → Agents). The parent owns the RouterProvider,
- * so navigation must be deferred to it — calling router.navigate() here races
- * with RouterProvider mounting.
- */
- navigateAfterComplete?: (nav: PostOnboardingNavigation) => void;
}) {
const [page, setPage] = React.useState(
identityLost ? "key-import" : (initialPage ?? "identity"),
@@ -98,6 +87,8 @@ export function MachineOnboardingFlow({
const [keyImportDialog, setKeyImportDialog] = React.useState<
"backup" | "phone" | null
>(null);
+ const [identityKeyHelpReturnPage, setIdentityKeyHelpReturnPage] =
+ React.useState<"identity" | "identity-key-intro">("identity");
const [phoneRecoveryStep, setPhoneRecoveryStep] = React.useState("loading");
const [selectedPubkey, setSelectedPubkey] = React.useState(
null,
@@ -106,6 +97,16 @@ export function MachineOnboardingFlow({
IdentityStorage | undefined
>();
const [readyRuntimeIds, setReadyRuntimeIds] = React.useState([]);
+ const [setupBackAction, setSetupBackAction] = React.useState<
+ (() => void) | null
+ >(null);
+ const [harnessConnectionMethod, setHarnessConnectionMethod] =
+ React.useState(null);
+ const [configBackTarget, setConfigBackTarget] = React.useState<
+ "method" | "list"
+ >("method");
+ const [isChoosingDifferentHarness, setIsChoosingDifferentHarness] =
+ React.useState(false);
const [defaultConfigDraft, setDefaultConfigDraft] =
React.useState(null);
const [isDefaultConfigSaving, setIsDefaultConfigSaving] =
@@ -117,17 +118,30 @@ export function MachineOnboardingFlow({
>("forward");
const [returningFromSecurity, setReturningFromSecurity] =
React.useState(false);
- // Owned here so switching between the yellow onboarding view and the dark
- // security subview keeps the created backup, password, and test progress.
+ // Owned here so switching between the onboarding card and the security
+ // subview keeps the created backup, password, and test progress.
const backupSession = useEncryptedBackupSession();
const reduceMotion = useReducedMotion() ?? false;
- const isSecuritySubview = page === "backup" && backupSubview !== "created";
+ const setupSelectionHandoffRef = React.useRef(false);
const handleReadyRuntimeIdsChange = React.useCallback(
(runtimeIds: readonly string[]) => {
+ if (setupSelectionHandoffRef.current) return;
setReadyRuntimeIds(Array.from(new Set(runtimeIds)));
},
[],
);
+ const handleSetupBackActionChange = React.useCallback(
+ (backAction: () => void) =>
+ setSetupBackAction((current) =>
+ current === backAction ? current : backAction,
+ ),
+ [],
+ );
+ const returnToApiConfig = React.useCallback(() => {
+ setIsChoosingDifferentHarness(false);
+ setTransitionDirection("backward");
+ setPage("config");
+ }, []);
const loadFreshIdentity = React.useCallback(async () => {
setIsPending(true);
@@ -218,9 +232,14 @@ export function MachineOnboardingFlow({
setKeyImportStage("key-entry");
return;
}
+ if (keyImportDialog) {
+ setKeyImportDialog(null);
+ setPhoneRecoveryStep("loading");
+ return;
+ }
setTransitionDirection("backward");
setPage("identity");
- }, [keyImportStage]);
+ }, [keyImportDialog, keyImportStage]);
const returnToCreatedKey = React.useCallback(() => {
setBackupDirection("backward");
@@ -231,8 +250,8 @@ export function MachineOnboardingFlow({
const backFromPasswordBackup = React.useCallback(() => {
resetEncryptedBackupSession(backupSession);
setBackupDirection("backward");
- setReturningFromSecurity(false);
- setBackupSubview("options");
+ setReturningFromSecurity(true);
+ setBackupSubview("created");
}, [backupSession]);
const backFromSetup = React.useCallback(() => {
@@ -252,60 +271,70 @@ export function MachineOnboardingFlow({
setPage("backup");
}, [backupSession, backupSubview, identityWasImported]);
+ const backFromConfig = React.useCallback(() => {
+ setupSelectionHandoffRef.current = false;
+ setTransitionDirection("backward");
+ setIsChoosingDifferentHarness(false);
+ if (configBackTarget === "method") {
+ setHarnessConnectionMethod(null);
+ }
+ setPage("setup");
+ }, [configBackTarget]);
+
const chromeBackAction =
- page === "key-import" &&
- (!identityLost || keyImportStage === "backup-password")
- ? { disabled: isKeyImporting, onClick: backFromKeyImport }
- : page === "backup" && backupSubview !== "created"
+ page === "identity-key-help"
+ ? {
+ onClick: () => {
+ setTransitionDirection("backward");
+ setPage(identityKeyHelpReturnPage);
+ },
+ }
+ : page === "identity-key-intro"
? {
- label: "Return to onboarding",
- onClick: returnToCreatedKey,
- testId: "backup-return-to-onboarding",
+ disabled: isPending,
+ onClick: () => {
+ setError(null);
+ setTransitionDirection("backward");
+ setPage("identity");
+ },
}
- : page === "backup"
- ? {
- onClick: () => {
- setTransitionDirection("backward");
- setPage("identity");
- },
- }
- : page === "setup"
- ? { onClick: backFromSetup }
- : page === "config"
+ : page === "key-import" &&
+ (keyImportDialog !== null ||
+ !identityLost ||
+ keyImportStage === "backup-password")
+ ? { disabled: isKeyImporting, onClick: backFromKeyImport }
+ : page === "backup" && backupSubview !== "created"
+ ? {
+ label: "Return to onboarding",
+ onClick: returnToCreatedKey,
+ testId: "backup-return-to-onboarding",
+ }
+ : page === "backup"
? {
- disabled: isDefaultConfigSaving,
onClick: () => {
setTransitionDirection("backward");
- setPage("setup");
+ setPage("identity-key-intro");
},
}
- : undefined;
+ : page === "setup"
+ ? { onClick: setupBackAction ?? backFromSetup }
+ : page === "config"
+ ? {
+ disabled: isDefaultConfigSaving,
+ onClick: backFromConfig,
+ }
+ : undefined;
- return (
-
-
- {page === "identity" ?
: null}
- {page !== "identity" && !isSecuritySubview ? (
-
- ) : null}
-
-
- {page === "identity" ? (
+ if (page === "identity") {
+ return (
+
+
+
+
+
void loadFreshIdentity()}
+ onClick={() => {
+ if (selectedPubkey) {
+ void loadFreshIdentity();
+ return;
+ }
+ setTransitionDirection("forward");
+ setPage("identity-key-intro");
+ }}
type="button"
>
{isPending
@@ -353,18 +389,103 @@ export function MachineOnboardingFlow({
: "Use an existing key"}
-
+ {
+ setIdentityKeyHelpReturnPage("identity");
+ setTransitionDirection("forward");
+ setPage("identity-key-help");
+ }}
+ />
- ) : page === "key-import" ? (
-
+
+
+ );
+ }
+
+ return (
+
+ {page === "identity-key-intro" ? (
+ void loadFreshIdentity()}
+ onOpenHelp={() => {
+ setError(null);
+ setIdentityKeyHelpReturnPage("identity-key-intro");
+ setTransitionDirection("forward");
+ setPage("identity-key-help");
+ }}
+ />
+ ) : page === "identity-key-help" ? (
+
+
+
+ ) : page === "key-import" ? (
+
+ {keyImportDialog === "backup" ? (
+
+
+ Restore from a backup file
+
+
+ Choose the encrypted backup file you saved from Buzz.
+
+
+
+ ) : keyImportDialog === "phone" ? (
+
+
+ {identityLost ? "Recover from your phone" : "Scan to sign in"}
+
+
+ {phoneRecoveryStep === "loading" || phoneRecoveryStep === "qr"
+ ? "Scan this code with a device where you’re currently signed in to Buzz."
+ : "Confirm the code before sharing your identity."}
+
+
+
+
+
+ ) : (
+ <>
-
+
{keyImportStage === "backup-password" ? (
"Enter your backup password to restore your identity."
) : (
@@ -386,8 +507,11 @@ export function MachineOnboardingFlow({
setKeyImportDialog("backup")}
+ disabled={isPending || isKeyImporting}
+ onClick={() => {
+ setKeyImportStage("key-entry");
+ setKeyImportDialog("backup");
+ }}
type="button"
>
backup file
@@ -396,8 +520,11 @@ export function MachineOnboardingFlow({
setKeyImportDialog("phone")}
+ disabled={isPending || isKeyImporting}
+ onClick={() => {
+ setPhoneRecoveryStep("loading");
+ setKeyImportDialog("phone");
+ }}
type="button"
>
recover from your phone
@@ -407,8 +534,8 @@ export function MachineOnboardingFlow({
)}
-
-
+
+
-
{
- if (!open) setKeyImportDialog(null);
- }}
- open={keyImportDialog === "backup"}
- >
-
-
-
- Restore from a backup file
-
-
- Choose the encrypted backup file you saved from Buzz.
-
- setKeyImportDialog(null)}
- onImport={importExistingIdentity}
- showBack={false}
- variant="spotlight"
- />
-
-
-
-
{
- if (!open) setKeyImportDialog(null);
- }}
- open={keyImportDialog === "phone"}
- >
-
-
-
- {identityLost
- ? "Recover from your phone"
- : "Use your Buzz identity"}
-
-
- {phoneRecoveryStep === "loading" ||
- phoneRecoveryStep === "qr"
- ? "Scan this code with a signed-in Buzz phone."
- : "Confirm the code before sharing your identity."}
-
-
-
-
-
-
-
-
- ) : page === "backup" ? (
- backupSubview === "password" ? (
-
- ) : (
-
{
- setTransitionDirection("forward");
- setPage("setup");
- }}
- onOpenPasswordBackup={() => {
- resetEncryptedBackupSession(backupSession);
- setBackupDirection("forward");
- setReturningFromSecurity(false);
- setBackupSubview("password");
- }}
- onShowOptions={() => {
- setBackupDirection("forward");
- setReturningFromSecurity(false);
- setBackupSubview("options");
- }}
- optionsExpanded={backupSubview === "options"}
- returningFromSecurity={returningFromSecurity}
- />
- )
- ) : page === "setup" ? (
- {
- backFromSetup();
- },
- next: (runtimeIds) => {
- const ids = Array.from(runtimeIds);
- setReadyRuntimeIds(ids);
- // Harness install can fail (Windows/PATH/network). Don't soft-lock
- // onboarding — users can finish setup later in Settings → Agents.
- if (ids.length === 0) {
- complete(selectedPubkey ?? undefined);
- return;
- }
- setTransitionDirection("forward");
- setPage("config");
- },
- navigateToAgentSettings: () => {
- // Complete onboarding first, then delegate the Settings → Agents
- // navigation to the parent. The parent owns RouterProvider, so
- // navigation from within the onboarding flow races with the
- // router mounting — calling router.navigate() here is unsafe.
- complete(selectedPubkey ?? undefined);
- navigateAfterComplete?.({
- to: "/settings",
- search: { section: "agents" },
- });
- },
- }}
- direction={transitionDirection}
- onReadyRuntimeIdsChange={handleReadyRuntimeIdsChange}
- />
- ) : (
- {
- setTransitionDirection("backward");
- setPage("setup");
- },
- complete: () => complete(selectedPubkey ?? undefined),
- discardDraft: () => setDefaultConfigDraft(null),
- updateDraft: setDefaultConfigDraft,
- }}
- direction={transitionDirection}
- draft={defaultConfigDraft}
- onSavingChange={setIsDefaultConfigSaving}
- readyRuntimeIds={readyRuntimeIds}
- />
+ >
)}
-
-
-
+
+ ) : page === "backup" ? (
+ backupSubview === "password" ? (
+
+ ) : (
+
{
+ setTransitionDirection("forward");
+ setPage("setup");
+ }}
+ onOpenPasswordBackup={() => {
+ resetEncryptedBackupSession(backupSession);
+ setBackupDirection("forward");
+ setReturningFromSecurity(false);
+ setBackupSubview("password");
+ }}
+ optionsExpanded={false}
+ returningFromSecurity={returningFromSecurity}
+ />
+ )
+ ) : page === "setup" ? (
+ {
+ backFromSetup();
+ },
+ next: (runtimeIds, nextConfigBackTarget = "list") => {
+ const ids = Array.from(runtimeIds);
+ setupSelectionHandoffRef.current = ids.length > 0;
+ setReadyRuntimeIds(ids);
+ // Harness install can fail (Windows/PATH/network). Don't soft-lock
+ // onboarding — users can finish setup later in Settings → Agents.
+ if (ids.length === 0) {
+ complete(selectedPubkey ?? undefined, {
+ continueToProfile: !identityWasImported,
+ });
+ return;
+ }
+ setConfigBackTarget(nextConfigBackTarget);
+ setIsChoosingDifferentHarness(false);
+ setTransitionDirection("forward");
+ setPage("config");
+ },
+ }}
+ direction={transitionDirection}
+ initialMethod={harnessConnectionMethod}
+ onInitialListBack={
+ isChoosingDifferentHarness ? returnToApiConfig : undefined
+ }
+ onBackActionChange={handleSetupBackActionChange}
+ onMethodChange={setHarnessConnectionMethod}
+ onReadyRuntimeIdsChange={handleReadyRuntimeIdsChange}
+ />
+ ) : (
+ {
+ backFromConfig();
+ },
+ complete: () =>
+ complete(selectedPubkey ?? undefined, {
+ continueToProfile: !identityWasImported,
+ }),
+ discardDraft: () => setDefaultConfigDraft(null),
+ updateDraft: setDefaultConfigDraft,
+ useDifferentHarness:
+ harnessConnectionMethod === "api"
+ ? () => {
+ setIsChoosingDifferentHarness(true);
+ setTransitionDirection("forward");
+ setPage("setup");
+ }
+ : undefined,
+ }}
+ direction={transitionDirection}
+ draft={defaultConfigDraft}
+ onSavingChange={setIsDefaultConfigSaving}
+ readyRuntimeIds={readyRuntimeIds}
+ />
+ )}
+
);
}
diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
index 814f2287412..ac69cf3d1fa 100644
--- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
+++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx
@@ -16,11 +16,14 @@ import {
ONBOARDING_PRIMARY_CTA_CLASS,
ONBOARDING_SECONDARY_CTA_CLASS,
} from "./OnboardingChrome";
+import { useOnboardingCardLayout } from "./OnboardingCard";
import {
BackupFileUnlockPreview,
BackupPasswordTimeline,
} from "./BackupPasswordTimeline";
import { OnboardingFooter } from "./OnboardingFooter";
+import { OnboardingInput } from "./OnboardingInput";
+import { ONBOARDING_CARD_INPUT_CLASS } from "./onboardingCardStyles";
const NOSTR_KEY_FILE_MAX_BYTES = 1024;
@@ -76,6 +79,7 @@ export function NostrKeyImportForm({
const [isDragging, setIsDragging] = React.useState(false);
const dragDepthRef = React.useRef(0);
const [isRevealed, setIsRevealed] = React.useState(false);
+ const cardLayout = useOnboardingCardLayout();
const inputRef = React.useRef(null);
const passphraseInputRef = React.useRef(null);
const fileInputRef = React.useRef(null);
@@ -275,30 +279,24 @@ export function NostrKeyImportForm({
}}
>
{!isPasswordStage && mode === "key" ? (
-
+
Private key
{variant === "spotlight" ? (
-
+ cardLayout ? (
- {
@@ -319,7 +317,7 @@ export function NostrKeyImportForm({
isRevealed ? "Hide private key" : "Reveal private key"
}
className={cn(
- "absolute right-8 top-1/2 h-10 w-10 -translate-y-1/2 text-muted-foreground transition-opacity duration-300 hover:bg-foreground/10 hover:text-foreground motion-reduce:transition-none",
+ "absolute right-2 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground transition-opacity duration-300 hover:bg-foreground/10 hover:text-foreground motion-reduce:transition-none",
hasInput ? "opacity-100" : "pointer-events-none opacity-0",
)}
data-testid="nostr-import-reveal-toggle"
@@ -330,13 +328,65 @@ export function NostrKeyImportForm({
variant="ghost"
>
{isRevealed ? (
-
+
) : (
-
+
)}
-
+ ) : (
+
+
+ {
+ setNsecInput(event.target.value);
+ setImportError(null);
+ }}
+ placeholder="Enter your key here"
+ ref={inputRef}
+ spellCheck={false}
+ type={isRevealed ? "text" : "password"}
+ value={nsecInput}
+ />
+ setIsRevealed((current) => !current)}
+ size="icon"
+ tabIndex={hasInput ? 0 : -1}
+ type="button"
+ variant="ghost"
+ >
+ {isRevealed ? (
+
+ ) : (
+
+ )}
+
+
+
+ )
) : (
{isDragging ? (
@@ -487,7 +537,10 @@ export function NostrKeyImportForm({
{isPasswordStage ? (
@@ -535,7 +588,8 @@ export function NostrKeyImportForm({
@@ -547,7 +601,12 @@ export function NostrKeyImportForm({
className="space-y-1 text-sm"
data-testid="nostr-import-npub-preview"
>
-
+
Nostr identity found
diff --git a/desktop/src/features/onboarding/ui/OnboardingCard.tsx b/desktop/src/features/onboarding/ui/OnboardingCard.tsx
new file mode 100644
index 00000000000..ea98933f55f
--- /dev/null
+++ b/desktop/src/features/onboarding/ui/OnboardingCard.tsx
@@ -0,0 +1,90 @@
+import * as React from "react";
+
+import { cn } from "@/shared/lib/cn";
+import { Card } from "@/shared/ui/card";
+import { useSmoothCorners } from "@/shared/ui/smoothCorners";
+import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
+import { OnboardingChrome } from "./OnboardingChrome";
+import {
+ type OnboardingBackAction,
+ OnboardingFooterProvider,
+} from "./OnboardingFooter";
+
+const OnboardingCardLayoutContext = React.createContext(false);
+
+export function useOnboardingCardLayout() {
+ return React.useContext(OnboardingCardLayoutContext);
+}
+
+/**
+ * Production onboarding shell for all steps after the landing screen. The
+ * page keeps the existing onboarding backdrop while navigation and content
+ * live together inside one stable card.
+ */
+export function OnboardingCard({
+ allowWideContent = false,
+ backAction,
+ children,
+ current,
+ showStepIndicator = true,
+ stableWideWidth = false,
+ systemColorScheme,
+ testId,
+ total,
+}: {
+ allowWideContent?: boolean;
+ backAction?: OnboardingBackAction;
+ children: React.ReactNode;
+ current: number;
+ showStepIndicator?: boolean;
+ /** Holds wide, mode-switching steps at the card's full width. */
+ stableWideWidth?: boolean;
+ systemColorScheme?: "dark" | "light";
+ testId: string;
+ total?: number;
+}) {
+ const cardRef = React.useRef
(null);
+ useSmoothCorners(cardRef);
+
+ return (
+
+
+ {showStepIndicator ? (
+
+ ) : null}
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/desktop/src/features/onboarding/ui/OnboardingChrome.tsx b/desktop/src/features/onboarding/ui/OnboardingChrome.tsx
index 7a52ae49993..55c8b7164ab 100644
--- a/desktop/src/features/onboarding/ui/OnboardingChrome.tsx
+++ b/desktop/src/features/onboarding/ui/OnboardingChrome.tsx
@@ -67,7 +67,7 @@ export function OnboardingChrome({
{Array.from({ length: total }, (_, i) => i + 1).map((position) => (
diff --git a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx
index f859414cf9f..a851250f056 100644
--- a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx
+++ b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx
@@ -15,10 +15,7 @@ import {
} from "@/shared/api/tauriIdentity";
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
import { Button } from "@/shared/ui/button";
-import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { AvatarStep } from "./AvatarStep";
-import { OnboardingChrome } from "./OnboardingChrome";
-import { OnboardingFooterProvider } from "./OnboardingFooter";
import { MembershipDenied } from "./MembershipDenied";
import {
NostrKeyImportForm,
@@ -30,6 +27,8 @@ import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
} from "./OnboardingSlideTransition";
+import { OnboardingCard } from "./OnboardingCard";
+import { TOTAL_ONBOARDING_PAGES } from "./OnboardingChrome";
import { ProfileStep } from "./ProfileStep";
import type {
OnboardingActions,
@@ -87,6 +86,7 @@ type OnboardingFlowProps = {
actions: OnboardingActions;
identityLost?: boolean;
initialProfile: OnboardingProfileSeed;
+ initialProfileDecisionSettled: boolean;
};
function isFallbackDisplayName(value?: string | null) {
@@ -155,6 +155,7 @@ export function OnboardingFlow({
actions,
identityLost = false,
initialProfile,
+ initialProfileDecisionSettled,
}: OnboardingFlowProps) {
const { complete, skipForNow } = actions;
const { activeCommunity } = useCommunities();
@@ -230,7 +231,7 @@ export function OnboardingFlow({
const saveProfileAndContinue = React.useCallback(
async (nextPage: OnboardingPage | "complete") => {
- if (isProfileAdvancePending) {
+ if (!initialProfileDecisionSettled || isProfileAdvancePending) {
return;
}
if (profileDraft.displayName.trim().length === 0) {
@@ -315,6 +316,7 @@ export function OnboardingFlow({
},
[
currentPage,
+ initialProfileDecisionSettled,
isProfileAdvancePending,
profileDraft,
profileUpdateMutation,
@@ -358,6 +360,7 @@ export function OnboardingFlow({
draftUrl: profileDraft.avatarUrl,
savedUrl: savedProfile.avatarUrl,
},
+ isReadyToSubmit: initialProfileDecisionSettled,
isUploadingAvatar,
isSaving: isSavingProfile || isProfileAdvancePending,
name: {
@@ -382,13 +385,13 @@ export function OnboardingFlow({
// Machine-level identity, backup, and provider setup have already completed.
// This relay-scoped flow now owns only the community profile.
const activeSteps: OnboardingPage[] = ["profile", "avatar"];
- const STEP_OFFSET = 1;
+ const STEP_OFFSET = 5;
// key-import occupies the same position as profile.
const normalizedPage: OnboardingPage =
currentPage === "key-import" ? "profile" : currentPage;
const pageIndex = activeSteps.indexOf(normalizedPage);
const currentStep = pageIndex >= 0 ? pageIndex + STEP_OFFSET : STEP_OFFSET;
- const totalOnboardingSteps = activeSteps.length;
+ const totalOnboardingSteps = TOTAL_ONBOARDING_PAGES;
// Swapping the identity changes the pubkey, which remounts this flow
// (keyed on pubkey in App.tsx) and re-runs the onboarding gate: the new
@@ -499,140 +502,136 @@ export function OnboardingFlow({
return (
<>
-
-
-
-
-
- {membershipError &&
- (currentPage === "profile" || currentPage === "avatar") ? (
-
- {membershipError.kind === "unreachable" ? (
+
+ {membershipError &&
+ (currentPage === "profile" || currentPage === "avatar") ? (
+
+ {membershipError.kind === "unreachable" ? (
+ <>
+
+ Can't reach this relay
+
+
+ Check your connection or change your community.
+
+
setIsCommunityChangeOpen(true)}
+ size="sm"
+ variant="outline"
+ >
+ Change community
+
+ >
+ ) : (
+ <>
+
+ {membershipError.message ?? "Something went wrong"}
+
+
+ The relay returned an error. Try again.
+
+ >
+ )}
+
+ ) : null}
+
+ {currentPage === "profile" ? (
+
{
+ void saveProfileAndContinue("avatar");
+ },
+ updateAvatarUrl: updateAvatarUrlDraft,
+ updateDisplayName: updateDisplayNameDraft,
+ }}
+ direction={transitionDirection}
+ state={profileStepState}
+ usesExistingIdentity
+ />
+ ) : currentPage === "key-import" ? (
+
+
+ {identityLost ? (
<>
-
- Can't reach this relay
-
-
- Check your connection or change your community.
+
+ Re-import your key
+
+
+ Your identity is no longer in the system keyring.
+ Re-import your nsec to restore it — Buzz will restart to
+ finish recovery. Or go back to start a new identity with a
+ fresh key.
-
setIsCommunityChangeOpen(true)}
- size="sm"
- variant="outline"
- >
- Change community
-
>
) : (
<>
-
- {membershipError.message ?? "Something went wrong"}
-
-
- The relay returned an error. Try again.
+
+ Use your existing key
+
+
+ Import your Nostr private key to use that identity with
+ Buzz. If this key already has a profile on the relay, your
+ name and avatar are restored automatically.
>
)}
- ) : null}
-
- {currentPage === "profile" ? (
- {
- void saveProfileAndContinue("avatar");
- },
- updateAvatarUrl: updateAvatarUrlDraft,
- updateDisplayName: updateDisplayNameDraft,
- }}
- direction={transitionDirection}
- state={profileStepState}
- usesExistingIdentity
- />
- ) : currentPage === "key-import" ? (
-
-
- {identityLost ? (
- <>
-
- Re-import your key
-
-
- Your identity is no longer in the system keyring.
- Re-import your nsec to restore it — Buzz will restart to
- finish recovery. Or go back to start a new identity with
- a fresh key.
-
- >
- ) : (
- <>
-
- Use your existing key
-
-
- Import your Nostr private key to use that identity with
- Buzz. If this key already has a profile on the relay,
- your name and avatar are restored automatically.
-
- >
- )}
-
-
- {persistError ? (
-
- {persistError}
-
- ) : null}
-
-
-
- ) : (
- {
- void saveProfileAndContinue("complete");
- },
- updateAvatarUrl: updateAvatarUrlDraft,
- }}
- direction={transitionDirection}
- showAlwaysSkip={true}
+
+ {persistError ? (
+
+ {persistError}
+
+ ) : null}
+
+
- )}
-
-
-
+
+ ) : (
+
{
+ void saveProfileAndContinue("complete");
+ },
+ updateAvatarUrl: updateAvatarUrlDraft,
+ }}
+ direction={transitionDirection}
+ showAlwaysSkip={true}
+ showBack={false}
+ state={avatarStepState}
+ />
+ )}
+
+
{isCommunityChangeOpen ? (
setIsCommunityChangeOpen(false)}
diff --git a/desktop/src/features/onboarding/ui/OnboardingFooter.tsx b/desktop/src/features/onboarding/ui/OnboardingFooter.tsx
index 620d2c7a0bc..317afa2a313 100644
--- a/desktop/src/features/onboarding/ui/OnboardingFooter.tsx
+++ b/desktop/src/features/onboarding/ui/OnboardingFooter.tsx
@@ -1,12 +1,20 @@
import * as React from "react";
import { createPortal } from "react-dom";
+import { ChevronLeft } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
+import { ONBOARDING_CARD_SECONDARY_CTA_CLASS } from "./onboardingCardStyles";
-const OnboardingFooterTargetContext = React.createContext(
- null,
-);
+type OnboardingFooterPlacement = "card" | "viewport";
+
+type OnboardingFooterTarget = {
+ element: HTMLElement | null;
+ placement: OnboardingFooterPlacement;
+};
+
+const OnboardingFooterTargetContext =
+ React.createContext(null);
/** Configuration for the provider-rendered, bottom-docked Back button. */
export type OnboardingBackAction = {
@@ -32,14 +40,60 @@ export type OnboardingBackAction = {
export function OnboardingFooterProvider({
backAction,
children,
+ contentClassName,
+ placement = "viewport",
}: {
backAction?: OnboardingBackAction;
children: React.ReactNode;
+ contentClassName?: string;
+ placement?: OnboardingFooterPlacement;
}) {
const [target, setTarget] = React.useState(null);
+ if (placement === "card") {
+ return (
+
+ {children}
+
+
+ {backAction ? (
+
+
+
+ ) : null}
+
+
+
+
+ );
+ }
+
return (
-
+
{children}
{/* Scrim: on pages taller than the viewport, content scrolls under the
docked CTA. This bottom-anchored fade to the shell's bottom color
@@ -85,11 +139,15 @@ export function OnboardingFooter({
children: React.ReactNode;
className?: string;
}) {
- const target = React.useContext(OnboardingFooterTargetContext);
+ const targetContext = React.useContext(OnboardingFooterTargetContext);
+ const target = targetContext?.element ?? null;
+ const placement = targetContext?.placement ?? "viewport";
const group = (
& {
+ smooth?: boolean;
+};
+
+/**
+ * Onboarding input with smooth clipping on the field and an unclipped focus
+ * frame, matching the card treatment without changing the shared Input.
+ */
+export const OnboardingInput = React.forwardRef<
+ HTMLInputElement,
+ OnboardingInputProps
+>(({ className, onBlur, onFocus, smooth = true, ...props }, forwardedRef) => {
+ const inputRef = React.useRef
(null);
+ const [isFocused, setIsFocused] = React.useState(false);
+ useSmoothCorners(inputRef, { enabled: smooth });
+
+ const setInputRef = React.useCallback(
+ (node: HTMLInputElement | null) => {
+ inputRef.current = node;
+ if (typeof forwardedRef === "function") {
+ forwardedRef(node);
+ } else if (forwardedRef) {
+ forwardedRef.current = node;
+ }
+ },
+ [forwardedRef],
+ );
+
+ return (
+
+ {
+ setIsFocused(false);
+ onBlur?.(event);
+ }}
+ onFocus={(event) => {
+ setIsFocused(true);
+ onFocus?.(event);
+ }}
+ ref={setInputRef}
+ {...props}
+ />
+
+ );
+});
+OnboardingInput.displayName = "OnboardingInput";
diff --git a/desktop/src/features/onboarding/ui/ProfileStep.tsx b/desktop/src/features/onboarding/ui/ProfileStep.tsx
index 69da9b8f7cb..803b3194955 100644
--- a/desktop/src/features/onboarding/ui/ProfileStep.tsx
+++ b/desktop/src/features/onboarding/ui/ProfileStep.tsx
@@ -1,4 +1,5 @@
import * as React from "react";
+import { createPortal } from "react-dom";
import { toast } from "sonner";
import { SidebarRelayConnectionCompactCard } from "@/features/sidebar/ui/SidebarRelayConnectionCard";
@@ -9,7 +10,9 @@ import { isRelayUnreachableError } from "@/shared/lib/relayError";
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 { OnboardingInput } from "./OnboardingInput";
import {
type OnboardingTransitionDirection,
type OnboardingTransitionEffect,
@@ -141,10 +144,11 @@ function OnboardingRelayConnectionErrorCard({
return null;
}
- return (
-
+ return createPortal(
+
-
+
,
+ document.body,
);
}
@@ -201,10 +206,12 @@ export function ProfileStep({
submit,
updateDisplayName,
} = actions;
- const { isSaving, name, saveRecovery } = state;
+ const { isReadyToSubmit, isSaving, name, saveRecovery } = state;
const displayNameDraft = name.draftValue;
const hasDisplayNameDraft = displayNameDraft.length > 0;
- const canSubmit = displayNameDraft.trim().length > 0 && !isSaving;
+ const canSubmit =
+ displayNameDraft.trim().length > 0 && isReadyToSubmit && !isSaving;
+ const cardLayout = useOnboardingCardLayout();
const inputRef = React.useRef(null);
React.useLayoutEffect(() => {
@@ -213,7 +220,10 @@ export function ProfileStep({
return (
-
- Name
-
- {!hasDisplayNameDraft ? (
-
-
-
- Enter your name
-
-
- ) : null}
-
+
Name
+
-
-
+
+ ) : (
+
+ Name
+
+ {!hasDisplayNameDraft ? (
+
+
+
+ Enter your name
+
+
+ ) : null}
+
updateDisplayName(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" && canSubmit) {
+ event.preventDefault();
+ submit();
+ }
+ }}
+ ref={inputRef}
+ spellCheck={false}
+ value={displayNameDraft}
+ />
+
+
+ )}
{saveRecovery.errorMessage ? (
@@ -322,32 +361,34 @@ export function ProfileStep({
) : null}
-
-
- {saveRecovery.canSkipForNow ? (
-
- Skip for now
-
- ) : null}
- {saveRecovery.canAdvanceWithoutSaving ? (
-
- Continue without saving
-
- ) : null}
-
-
+ {saveRecovery.canSkipForNow || saveRecovery.canAdvanceWithoutSaving ? (
+
+
+ {saveRecovery.canSkipForNow ? (
+
+ Skip for now
+
+ ) : null}
+ {saveRecovery.canAdvanceWithoutSaving ? (
+
+ Continue without saving
+
+ ) : null}
+
+
+ ) : null}
);
diff --git a/desktop/src/features/onboarding/ui/SetupStep.acpForcedGate.test.mjs b/desktop/src/features/onboarding/ui/SetupStep.acpForcedGate.test.mjs
index c360256438e..db1e233bc9d 100644
--- a/desktop/src/features/onboarding/ui/SetupStep.acpForcedGate.test.mjs
+++ b/desktop/src/features/onboarding/ui/SetupStep.acpForcedGate.test.mjs
@@ -1,13 +1,7 @@
/**
- * Mounted consumer regressions for the SetupStep forced-probe readiness gate.
- *
- * P1: isChecking = isFetching (not isLoading) ensures the Next button stays
- * disabled while the forced probe is in flight or has rejected, even when
- * cached data exists. With the old isLoading mapping, isLoading is false when
- * data is present, so the button was incorrectly enabled.
- *
- * Mutation proof: revert only the SetupStep.tsx hunk and both tests go RED
- * (button enabled in states it must block).
+ * Mounted consumer regressions for SetupStep cached-ready revalidation.
+ * A warm forced probe still runs on entry, but cached readiness remains
+ * visually stable unless that probe fails.
*/
import assert from "node:assert/strict";
@@ -166,7 +160,7 @@ function deferred() {
}
const NOOP = () => {};
-const ACTIONS = { back: NOOP, next: NOOP, navigateToAgentSettings: NOOP };
+const ACTIONS = { back: NOOP, next: NOOP };
/** Mount SetupStep under the query client + tooltip provider it requires. */
function renderSetupStep() {
@@ -176,7 +170,12 @@ function renderSetupStep() {
return { container, root };
}
-function setupStepTree(queryClient) {
+function setupStepTree(
+ queryClient,
+ actions = ACTIONS,
+ onReadyRuntimeIdsChange = NOOP,
+ initialMethod = "subscription",
+) {
return React.createElement(
QueryClientProvider,
{ client: queryClient },
@@ -184,9 +183,10 @@ function setupStepTree(queryClient) {
TooltipProvider,
null,
React.createElement(SetupStep, {
- actions: ACTIONS,
+ actions,
direction: "forward",
- onReadyRuntimeIdsChange: NOOP,
+ initialMethod,
+ onReadyRuntimeIdsChange,
}),
),
);
@@ -194,12 +194,9 @@ function setupStepTree(queryClient) {
// ── Tests ─────────────────────────────────────────────────────────────────────
-describe("SetupStep Next button readiness gate — P1 regression (mounted consumer)", () => {
- it("onboarding-setup-next is disabled while forced probe is pending over cached data", async () => {
+describe("SetupStep cached-ready revalidation", () => {
+ it("keeps a cached ready harness available while a warm forced probe is pending", async () => {
const queryClient = makeQueryClient();
- // Pre-seed cache with a ready runtime. getReadyOnboardingRuntimes
- // will return it, so readyRuntimeIds.length > 0 — proving the button
- // is blocked by isChecking, not by an empty ready set.
queryClient.setQueryData(acpRuntimesQueryKey, [
catalogEntry("codex", "logged_in"),
]);
@@ -208,111 +205,80 @@ describe("SetupStep Next button readiness gate — P1 regression (mounted consum
discoverHandler = (args) =>
args?.force === true ? pending.promise : Promise.resolve([]);
- const container = document.createElement("div");
- document.body.appendChild(container);
- const root = createRoot(container);
-
+ const nextCalls = [];
+ const readyRuntimeIdSnapshots = [];
+ const actions = {
+ ...ACTIONS,
+ next: (...args) => nextCalls.push(args),
+ };
+ const { container, root } = renderSetupStep();
await act(async () => {
root.render(
- React.createElement(
- QueryClientProvider,
- { client: queryClient },
- React.createElement(
- TooltipProvider,
- null,
- React.createElement(SetupStep, {
- actions: ACTIONS,
- direction: "forward",
- onReadyRuntimeIdsChange: NOOP,
- }),
- ),
+ setupStepTree(queryClient, actions, (runtimeIds) =>
+ readyRuntimeIdSnapshots.push([...runtimeIds]),
),
);
});
- // Let the mount-time forceRefresh dispatch (but not resolve).
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
- const button = container.querySelector(
- '[data-testid="onboarding-setup-next"]',
- );
- assert.ok(button, "onboarding-setup-next button must be present");
- assert.ok(
- button.disabled,
- "Next button must be disabled while forced probe is in flight over cached data",
+ const readyCard = container.querySelector(
+ '[data-testid="onboarding-runtime-codex"]',
);
-
- // Resolve the pending probe inside act so React Query drains its state
- // update before unmount — prevents "Promise resolution still pending"
- // from the dangling deferred.
+ assert.ok(readyCard, "the cached harness remains visible during recheck");
+ assert.equal(readyCard.getAttribute("data-ready"), "true");
await act(async () => {
- pending.resolve([]);
- await new Promise((r) => setTimeout(r, 0));
+ readyCard
+ .querySelector('[data-testid="onboarding-runtime-details-codex"]')
+ ?.click();
});
- await act(async () => {
- root.unmount();
- });
- container.remove();
- queryClient.clear();
- });
-
- it("onboarding-setup-next is disabled after forced probe rejects over cached data", async () => {
- const queryClient = makeQueryClient();
- queryClient.setQueryData(acpRuntimesQueryKey, [
- catalogEntry("codex", "logged_in"),
- ]);
-
- discoverHandler = (args) =>
- args?.force === true
- ? Promise.reject(new Error("forced probe rejected"))
- : Promise.resolve([]);
-
- const container = document.createElement("div");
- document.body.appendChild(container);
- const root = createRoot(container);
+ assert.equal(
+ nextCalls.length,
+ 0,
+ "cached readiness cannot navigate while the forced recheck is pending",
+ );
+ assert.deepEqual(
+ readyRuntimeIdSnapshots,
+ [],
+ "pending cached readiness is not exported as confirmed",
+ );
+ assert.equal(
+ container.querySelector('[data-testid="onboarding-runtime-ready-codex"]'),
+ null,
+ "the installed section does not repeat readiness with a tag",
+ );
+ assert.equal(
+ container.querySelector(
+ '[data-testid="onboarding-runtime-rechecking-codex"]',
+ ),
+ null,
+ "a warm recheck does not flash a redundant Checking state",
+ );
+ // Success preserves the stable ready card without adding a status tag.
await act(async () => {
- root.render(
- React.createElement(
- QueryClientProvider,
- { client: queryClient },
- React.createElement(
- TooltipProvider,
- null,
- React.createElement(SetupStep, {
- actions: ACTIONS,
- direction: "forward",
- onReadyRuntimeIdsChange: NOOP,
- }),
- ),
- ),
- );
- });
- await act(async () => {
+ pending.resolve([rawReadyEntry("codex")]);
await new Promise((r) => setTimeout(r, 50));
});
-
- const button = container.querySelector(
- '[data-testid="onboarding-setup-next"]',
- );
- assert.ok(button, "onboarding-setup-next button must be present");
- assert.ok(
- button.disabled,
- "Next button must be disabled after forced probe rejects, even with cached data",
- );
-
- const errorEl = container.querySelector(
- '[data-testid="onboarding-setup-error"]',
+ assert.equal(
+ container
+ .querySelector('[data-testid="onboarding-runtime-codex"]')
+ ?.getAttribute("data-ready"),
+ "true",
+ "the harness remains ready once the warm recheck succeeds",
);
- assert.ok(
- errorEl,
- "the forced rejection error must be rendered after the probe rejects",
+ assert.deepEqual(
+ readyRuntimeIdSnapshots,
+ [["codex"]],
+ "only a successful forced recheck exports cached readiness",
);
- assert.match(
- errorEl.textContent ?? "",
- /forced probe rejected/,
- "rendered error must surface the forced rejection message",
+ assert.equal(
+ container.querySelector(
+ '[data-testid="onboarding-runtime-rechecking-codex"]',
+ ),
+ null,
+ "no Checking indicator appears on success",
);
await act(async () => {
@@ -321,59 +287,66 @@ describe("SetupStep Next button readiness gate — P1 regression (mounted consum
container.remove();
queryClient.clear();
});
-});
-describe("SetupStep cached-ready revalidation — P4 regression (mounted consumer)", () => {
- it("cached READY is replaced by a CHECKING indicator while a warm forced probe is pending", async () => {
+ it("hands off only the API harness selected while forced discovery is pending", async () => {
const queryClient = makeQueryClient();
queryClient.setQueryData(acpRuntimesQueryKey, [
- catalogEntry("codex", "logged_in"),
+ catalogEntry("buzz-agent", "not_applicable"),
+ catalogEntry("goose", "not_applicable"),
]);
const pending = deferred();
discoverHandler = (args) =>
args?.force === true ? pending.promise : Promise.resolve([]);
+ const nextCalls = [];
+ const readyRuntimeIdSnapshots = [];
+ const actions = {
+ ...ACTIONS,
+ next: (...args) => nextCalls.push(args),
+ };
const { container, root } = renderSetupStep();
await act(async () => {
- root.render(setupStepTree(queryClient));
+ root.render(
+ setupStepTree(
+ queryClient,
+ actions,
+ (runtimeIds) => readyRuntimeIdSnapshots.push([...runtimeIds]),
+ null,
+ ),
+ );
});
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
+ container
+ .querySelector('[data-testid="onboarding-harness-method-api"]')
+ ?.click();
});
-
- assert.ok(
- container.querySelector(
- '[data-testid="onboarding-runtime-rechecking-codex"]',
- ),
- "a pending warm recheck over a cached-ready runtime must show CHECKING…",
- );
- assert.equal(
- container.querySelector('[data-testid="onboarding-runtime-ready-codex"]'),
- null,
- "cached READY must not be presented as current while the recheck is in flight",
+ assert.deepEqual(
+ nextCalls,
+ [],
+ "cached Buzz readiness cannot advance before forced discovery settles",
);
- // Success restores READY.
await act(async () => {
- pending.resolve([rawReadyEntry("codex")]);
+ pending.resolve([rawReadyEntry("buzz-agent"), rawReadyEntry("goose")]);
await new Promise((r) => setTimeout(r, 50));
});
- assert.ok(
- container.querySelector('[data-testid="onboarding-runtime-ready-codex"]'),
- "READY returns once the warm recheck succeeds",
+
+ assert.deepEqual(
+ nextCalls,
+ [[["buzz-agent"], "method"]],
+ "successful discovery advances with only the explicitly chosen API harness",
);
- assert.equal(
- container.querySelector(
- '[data-testid="onboarding-runtime-rechecking-codex"]',
+ assert.ok(
+ readyRuntimeIdSnapshots.some(
+ (snapshot) =>
+ snapshot.length === 2 &&
+ snapshot.includes("buzz-agent") &&
+ snapshot.includes("goose"),
),
- null,
- "the CHECKING indicator clears on success",
+ "catalog readiness may still be published independently of the selected handoff",
);
- const button = container.querySelector(
- '[data-testid="onboarding-setup-next"]',
- );
- assert.ok(button && !button.disabled, "Next is enabled after success");
await act(async () => {
root.unmount();
@@ -382,7 +355,7 @@ describe("SetupStep cached-ready revalidation — P4 regression (mounted consume
queryClient.clear();
});
- it("cached READY is replaced by a recheck affordance after a warm forced probe rejects", async () => {
+ it("replaces cached Ready with a recheck affordance after a warm forced probe rejects", async () => {
const queryClient = makeQueryClient();
queryClient.setQueryData(acpRuntimesQueryKey, [
catalogEntry("codex", "logged_in"),
@@ -416,14 +389,6 @@ describe("SetupStep cached-ready revalidation — P4 regression (mounted consume
container.querySelector('[data-testid="onboarding-setup-error"]'),
"the warm rejection error stays visible alongside the retained card",
);
- const button = container.querySelector(
- '[data-testid="onboarding-setup-next"]',
- );
- assert.ok(
- button && button.disabled,
- "Next stays gated while readiness is unconfirmed",
- );
-
await act(async () => {
root.unmount();
});
diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx
index aac9b53846a..125b5ecf4c0 100644
--- a/desktop/src/features/onboarding/ui/SetupStep.tsx
+++ b/desktop/src/features/onboarding/ui/SetupStep.tsx
@@ -1,6 +1,6 @@
import * as React from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
-import { Check, Info } from "lucide-react";
+import { Check, ChevronRight, ExternalLink } from "lucide-react";
import {
useAcpAuthMethodsQuery,
@@ -15,15 +15,20 @@ import { getInstallErrorMessage } from "@/shared/lib/installError";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
-import { FlappingBee } from "@/shared/ui/buzz-logo/FlappingBee";
import { Spinner } from "@/shared/ui/spinner";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
+import { ConnectionMethodSection } from "./ConnectionMethodSection";
import {
getReadyOnboardingRuntimes,
getVisibleOnboardingRuntimes,
runtimeIsReadyForOnboarding,
} from "./onboardingRuntimeSelection";
+import {
+ getRuntimesForConnectionMethod,
+ type HarnessConnectionMethod,
+ runtimeUnavailableDescription,
+} from "./harnessConnectionOptions";
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
+import { useOnboardingCardLayout } from "./OnboardingCard";
import { RuntimeErrorTooltip } from "./RuntimeErrorTooltip";
import { OnboardingFooter } from "./OnboardingFooter";
import { getRuntimeDisplayLabel, RuntimeIcon } from "./RuntimeIcon";
@@ -32,14 +37,18 @@ import {
OnboardingSlideTransition,
} from "./OnboardingSlideTransition";
import type { SetupStepActions, SetupStepState } from "./types";
-
type SetupStepProps = {
actions: SetupStepActions;
direction: OnboardingTransitionDirection;
+ initialMethod?: HarnessConnectionMethod | null;
+ onInitialListBack?: () => void;
+ onBackActionChange?: (backAction: () => void) => void;
+ onMethodChange?: (method: HarnessConnectionMethod | null) => void;
onReadyRuntimeIdsChange: (runtimeIds: readonly string[]) => void;
};
type SetupStepContentProps = SetupStepProps & {
+ onRefresh: () => void;
state: SetupStepState;
};
@@ -50,7 +59,15 @@ type InstallResultState = {
type InstallResultsState = Record;
-function useSetupStepState(): SetupStepState {
+const SUBSCRIPTION_NAMES: Record = {
+ amp: "Amp subscription",
+ claude: "Claude subscription",
+ codex: "ChatGPT subscription",
+ cursor: "Cursor subscription",
+ devin: "Devin account",
+};
+
+function useSetupStepState() {
const runtimesQuery = useAcpRuntimesQueryForced();
const items = runtimesQuery.data ?? [];
const isChecking = runtimesQuery.isFetching;
@@ -58,10 +75,14 @@ function useSetupStepState(): SetupStepState {
runtimesQuery.error instanceof Error ? runtimesQuery.error.message : null;
return {
- runtimeProviders: {
- errorMessage,
- isChecking,
- items,
+ onRefresh: () => void runtimesQuery.forceRefresh(),
+ state: {
+ runtimeProviders: {
+ errorMessage,
+ hasForcedCheckStarted: runtimesQuery.hasForcedCheckStarted,
+ isChecking,
+ items,
+ },
},
};
}
@@ -96,17 +117,20 @@ function RuntimeStatus({
installError,
isInstalling,
onInstall,
+ prominent = false,
runtime,
}: {
installError: string | null;
isInstalling: boolean;
onInstall: () => void;
+ prominent?: boolean;
runtime: AcpRuntimeCatalogEntry;
}) {
+ const shouldSignIn =
+ runtime.availability === "available" &&
+ runtime.authStatus.status === "logged_out";
const methodsQuery = useAcpAuthMethodsQuery(runtime.id, {
- enabled:
- runtime.availability === "available" &&
- runtime.authStatus.status === "logged_out",
+ enabled: shouldSignIn && prominent,
});
const connectMutation = useConnectAcpRuntimeMutation();
// Child rows share the surface owner's forced query state + refresh callback
@@ -146,16 +170,24 @@ function RuntimeStatus({
methodsQuery.data?.methods ?? [],
);
const authMethod = authMethods[0] ?? null;
- const shouldSignIn =
- runtime.availability === "available" &&
- runtime.authStatus.status === "logged_out";
if (shouldSignIn) {
+ if (!prominent) {
+ return (
+
+ Sign in required
+
+ );
+ }
+
return (
{
if (didSignInCheckTimeOut) {
@@ -182,10 +214,10 @@ function RuntimeStatus({
variant="ghost"
>
{isWaitingForSignIn
- ? "CHECKING…"
+ ? "Checking…"
: didSignInCheckTimeOut
- ? "CHECK AGAIN"
- : "SIGN IN"}
+ ? "Check again"
+ : "Sign in"}
{methodsQuery.error instanceof Error ? (
- INSTALLING
+ Installing
);
}
if (runtimeIsReadyForOnboarding(runtime)) {
- // Cached readiness must not read as freshly confirmed while a warm forced
- // probe is revalidating (or has rejected) over it. `runtimesQuery` shares
- // the surface owner's forced-query state, so its fetching/error flags track
- // the in-flight recheck. Pending → a visible CHECKING… state; a warm
- // rejection → a recheck affordance (never an unqualified READY). On success
- // both clear and READY returns. Next stays gated by isChecking/errorMessage
- // in SetupStepContent, so this only governs the per-card claim.
- if (runtimesQuery.isFetching) {
- return (
-
-
- CHECKING…
-
- );
- }
+ // Installed harnesses are already grouped above the "Not installed"
+ // section, so a second Ready label only repeats the list structure.
if (runtimesQuery.isError) {
return (
void runtimesQuery.forceRefresh()}
type="button"
variant="ghost"
>
- CHECK AGAIN
+ Check again
);
}
- return (
-
-
-
- READY
-
-
-
-
-
-
- );
+ return null;
}
if (
@@ -280,23 +277,23 @@ function RuntimeStatus({
return (
void runtimesQuery.forceRefresh()}
type="button"
variant="ghost"
>
- {runtimesQuery.isFetching ? "CHECKING…" : "CHECK AGAIN"}
+ {runtimesQuery.isFetching ? "Checking…" : "Check again"}
);
}
- const installLabel = installError ? "RETRY INSTALL" : "INSTALL";
+ const installLabel = installError ? "Retry install" : "Install";
if (runtime.canAutoInstall) {
return (
void openUrl(runtime.installInstructionsUrl)}
type="button"
variant="ghost"
>
- INSTALL
+ Install
);
}
-function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
- if (
- runtime.availability === "available" &&
- runtime.command &&
- runtime.binaryPath
- ) {
- const description = describeResolvedCommand(
- runtime.command,
- runtime.binaryPath,
- );
- return (
- <>
-
- {description.charAt(0).toUpperCase() + description.slice(1)}
-
- {runtime.defaultArgs.length > 0 ? (
-
- Args:{" "}
- {runtime.defaultArgs.join(", ")}
-
- ) : null}
- >
- );
- }
-
- if (runtime.availability === "adapter_missing") {
- return (
- <>
-
- CLI detected; ACP adapter missing.
-
-
- {runtime.installHint}
-
- >
- );
- }
-
- if (runtime.availability === "adapter_outdated") {
- return (
- <>
-
- ACP adapter detected but outdated — reinstall required.
-
-
- This updates the machine-global{" "}
-
- codex-acp
- {" "}
- adapter. Older Buzz releases using the legacy adapter contract may
- lose community access until{" "}
-
- @zed-industries/codex-acp@0.16.0
- {" "}
- is restored.
-
-
- {runtime.installHint}
-
- >
- );
- }
-
- if (runtime.availability === "cli_missing") {
- return (
- <>
-
- ACP adapter detected; CLI missing.
-
-
- {runtime.installHint}
-
- >
- );
- }
-
- return (
- <>
- Not installed yet.
- {runtime.installHint}
- >
- );
-}
-
function runtimeDetailText(runtime: AcpRuntimeCatalogEntry): string {
if (
runtime.availability === "available" &&
@@ -505,16 +418,23 @@ function RuntimeAuthError({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
}
function RuntimeCard({
+ detailCopy,
installResults,
+ isRecommended = false,
+ onOpenDetails,
onInstallResultsChange,
runtime,
}: {
+ detailCopy?: { description: string; title: string };
installResults: InstallResultsState;
+ isRecommended?: boolean;
+ onOpenDetails?: () => void;
onInstallResultsChange: React.Dispatch<
React.SetStateAction
>;
runtime: AcpRuntimeCatalogEntry;
}) {
+ const cardLayout = useOnboardingCardLayout();
// Each card owns its own mutation instance so concurrent installs on
// different cards each track their own isPending state and callbacks
// independently (react-query v5 per-mutate callbacks only fire for the
@@ -525,6 +445,29 @@ function RuntimeCard({
const installOutputLine = useInstallOutputLine(runtime.id, isInstalling);
const isAvailable = runtime.availability === "available";
const isReady = runtimeIsReadyForOnboarding(runtime);
+ const runtimeIdentity = (
+ <>
+
+
+
+
+
+ {detailCopy?.title ?? getRuntimeDisplayLabel(runtime)}
+
+ {detailCopy ? (
+
+ {detailCopy.description}
+
+ ) : null}
+
+ >
+ );
function handleInstall() {
onInstallResultsChange((current) => ({
@@ -556,10 +499,92 @@ function RuntimeCard({
});
}
+ if (cardLayout) {
+ return (
+
+ {onOpenDetails ? (
+
+
+ Open {getRuntimeDisplayLabel(runtime)} setup
+
+
+ ) : null}
+
+ {runtimeIdentity}
+
+ {isRecommended ? (
+
+ Recommended
+
+ ) : null}
+ {isAvailable && !isReady ? (
+
+
+
+ ) : null}
+
+ {onOpenDetails ? (
+
+
+
+ ) : null}
+
+ {installError ? (
+
+ ) : (
+
+ )}
+
+ );
+ }
+
return (
-
-
-
- Finding your providers...
-
-
+
+ Loading providers…
);
}
function RuntimeProvidersSection({
installResults,
- navigateToAgentSettings,
+ method,
onInstallResultsChange,
+ onOpenDetails,
runtimeProviders,
}: {
installResults: InstallResultsState;
- navigateToAgentSettings?: () => void;
+ method: HarnessConnectionMethod;
onInstallResultsChange: React.Dispatch<
React.SetStateAction
>;
+ onOpenDetails: (runtimeId: string) => void;
runtimeProviders: SetupStepState["runtimeProviders"];
}) {
+ const cardLayout = useOnboardingCardLayout();
const { errorMessage, isChecking, items } = runtimeProviders;
- const orderedItems = getVisibleOnboardingRuntimes(items);
+ const scrollRef = React.useRef(null);
+ const [canScrollUp, setCanScrollUp] = React.useState(false);
+ const [canScrollDown, setCanScrollDown] = React.useState(false);
+ const orderedItems = React.useMemo(() => {
+ const visible = getRuntimesForConnectionMethod(
+ getVisibleOnboardingRuntimes(items),
+ method,
+ );
+ if (method !== "api") return visible;
+
+ const group = (runtime: AcpRuntimeCatalogEntry) => {
+ if (runtime.id === "buzz-agent") return 0;
+ if (runtime.id === "goose") return 1;
+ return runtime.availability === "available" ? 2 : 3;
+ };
+ return [...visible].sort((left, right) => group(left) - group(right));
+ }, [items, method]);
+ const updateScrollEdges = React.useCallback(() => {
+ const element = scrollRef.current;
+ if (!element) return;
+ setCanScrollUp(element.scrollTop > 1);
+ setCanScrollDown(
+ element.scrollTop + element.clientHeight < element.scrollHeight - 1,
+ );
+ }, []);
+
+ React.useEffect(() => {
+ updateScrollEdges();
+ const element = scrollRef.current;
+ if (!element) return;
+ const observer = new ResizeObserver(updateScrollEdges);
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, [updateScrollEdges]);
return (
-
-
+
+
- Set up your agent harnesses
+ {method === "subscription"
+ ? "Continue with an AI subscription"
+ : "Choose a harness"}
-
- Buzz checks for command-line harnesses on this machine. Install the
- CLI or sign in to at least one to continue.
+
+ {method === "subscription"
+ ? "Subscriptions connect through a compatible harness, like Claude Code or Codex. Choose yours to sign in."
+ : "Choose how your agents will connect to AI providers. You can change this at any time."}
-
+
{orderedItems.length > 0 ? (
-
- {orderedItems.map((runtime) => (
-
- ))}
+
+ {cardLayout ? (
+ <>
+
+
+ >
+ ) : null}
+
+ {orderedItems.map((runtime, index) => {
+ const previousRuntime = orderedItems[index - 1];
+ const startsNotInstalledSection =
+ runtime.availability !== "available" &&
+ (previousRuntime === undefined ||
+ previousRuntime.availability === "available");
+
+ return (
+
+ {startsNotInstalledSection ? (
+
+ Not installed
+
+ ) : null}
+ onOpenDetails(runtime.id)}
+ runtime={runtime}
+ />
+
+ );
+ })}
+
) : isChecking ? (
@@ -685,8 +828,7 @@ function RuntimeProvidersSection({
className="max-w-[560px] rounded-2xl bg-white/70 px-6 py-6 text-sm text-muted-foreground"
data-testid="onboarding-acp-empty"
>
- No supported command-line harnesses were detected yet. Install a
- supported CLI, then check again.
+ No supported harnesses are available for this connection method.
)}
@@ -698,27 +840,128 @@ function RuntimeProvidersSection({
{errorMessage}
) : null}
+
+
+ );
+}
-
-
-
- More harnesses (Cursor, Grok, Amp…){" "}
- {navigateToAgentSettings ? (
-
- Settings → Agents
-
- ) : (
- Settings → Agents
- )}{" "}
- after setup.
-
+function RuntimeSetupGuide({
+ installResults,
+ method,
+ onInstallResultsChange,
+ onRefresh,
+ runtime,
+}: {
+ installResults: InstallResultsState;
+ method: HarnessConnectionMethod;
+ onInstallResultsChange: React.Dispatch<
+ React.SetStateAction
+ >;
+ onRefresh: () => void;
+ runtime: AcpRuntimeCatalogEntry;
+}) {
+ const label = getRuntimeDisplayLabel(runtime);
+ const available = runtime.availability === "available";
+ const subscriptionDetail =
+ method === "subscription"
+ ? {
+ description: `Buzz will open a sign-in window for ${label}.`,
+ title: SUBSCRIPTION_NAMES[runtime.id] ?? label,
+ }
+ : undefined;
+
+ if (!available) {
+ return (
+
+
+ Set up {label}
+
+
+ Follow the setup guide to install {label}. When you’re done, come back
+ and check again.
+
+
+
+
+
+
{label}
+
+ {runtimeUnavailableDescription(runtime)}
+
+
+
void openUrl(runtime.installInstructionsUrl)}
+ size="xs"
+ type="button"
+ >
+ Open guide
+
+
+
+
+
+
+
+ Check again
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ Connect {label}
+
+
+ Sign in to connect {label}. You can change this anytime.
+
+
+
+
+
+
+
+ Check again
+
+
);
}
@@ -726,65 +969,206 @@ function RuntimeProvidersSection({
function SetupStepContent({
actions,
direction,
+ initialMethod = null,
+ onInitialListBack,
+ onBackActionChange,
+ onMethodChange,
+ onRefresh,
onReadyRuntimeIdsChange,
state,
}: SetupStepContentProps) {
+ const cardLayout = useOnboardingCardLayout();
const { runtimeProviders } = state;
+ const readinessConfirmed =
+ runtimeProviders.hasForcedCheckStarted &&
+ !runtimeProviders.isChecking &&
+ runtimeProviders.errorMessage === null;
+ const [stage, setStage] = React.useState<"method" | "list" | "detail">(
+ initialMethod ? "list" : "method",
+ );
+ const [method, setMethod] = React.useState(
+ initialMethod,
+ );
+ const [selectedRuntimeId, setSelectedRuntimeId] = React.useState<
+ string | null
+ >(null);
+ const [detailConfigBackTarget, setDetailConfigBackTarget] = React.useState<
+ "method" | "list"
+ >("list");
+ const [localDirection, setLocalDirection] =
+ React.useState(direction);
const [installResults, setInstallResults] =
React.useState({});
const readyRuntimeIds = React.useMemo(
() =>
- getReadyOnboardingRuntimes(runtimeProviders.items).map(
- (runtime) => runtime.id,
- ),
- [runtimeProviders.items],
+ readinessConfirmed
+ ? getReadyOnboardingRuntimes(runtimeProviders.items).map(
+ (runtime) => runtime.id,
+ )
+ : [],
+ [readinessConfirmed, runtimeProviders.items],
);
const readyRuntimeIdsKey = readyRuntimeIds.join("\0");
- // The key prevents catalog object refreshes from creating an effect loop
- // when the detected ready IDs have not changed.
+ // Use an ID key so catalog object refreshes cannot loop the effect.
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed by ID content
React.useEffect(() => {
+ if (
+ !runtimeProviders.hasForcedCheckStarted ||
+ runtimeProviders.isChecking ||
+ runtimeProviders.errorMessage !== null
+ ) {
+ return;
+ }
onReadyRuntimeIdsChange(readyRuntimeIds);
- }, [onReadyRuntimeIdsChange, readyRuntimeIdsKey]);
+ }, [
+ onReadyRuntimeIdsChange,
+ readyRuntimeIdsKey,
+ runtimeProviders.errorMessage,
+ runtimeProviders.hasForcedCheckStarted,
+ runtimeProviders.isChecking,
+ runtimeProviders.items.length,
+ ]);
+ const selectedRuntime = runtimeProviders.items.find(
+ (runtime) => runtime.id === selectedRuntimeId,
+ );
+ const selectedRuntimeIsReady =
+ readinessConfirmed && selectedRuntime
+ ? runtimeIsReadyForOnboarding(selectedRuntime)
+ : false;
+ const actionsRef = React.useRef(actions);
+ actionsRef.current = actions;
+ const navigateBack = React.useCallback(() => {
+ setLocalDirection("backward");
+ if (stage === "detail") {
+ setStage("list");
+ setSelectedRuntimeId(null);
+ return;
+ }
+ if (stage === "list") {
+ if (onInitialListBack) {
+ onInitialListBack();
+ return;
+ }
+ setStage("method");
+ setMethod(null);
+ onMethodChange?.(null);
+ return;
+ }
+ actionsRef.current.back();
+ }, [onInitialListBack, onMethodChange, stage]);
+
+ React.useEffect(() => {
+ onBackActionChange?.(navigateBack);
+ }, [navigateBack, onBackActionChange]);
+
+ React.useLayoutEffect(() => {
+ if (stage !== "detail" || !selectedRuntime || !selectedRuntimeIsReady) {
+ return;
+ }
+ setLocalDirection("forward");
+ actionsRef.current.next([selectedRuntime.id], detailConfigBackTarget);
+ }, [detailConfigBackTarget, selectedRuntime, selectedRuntimeIsReady, stage]);
+
+ function chooseMethod(nextMethod: HarnessConnectionMethod) {
+ setMethod(nextMethod);
+ onMethodChange?.(nextMethod);
+ setLocalDirection("forward");
+
+ if (nextMethod === "api") {
+ const buzzRuntime = runtimeProviders.items.find(
+ (runtime) => runtime.id === "buzz-agent",
+ );
+ if (buzzRuntime) {
+ if (readinessConfirmed && runtimeIsReadyForOnboarding(buzzRuntime)) {
+ actions.next([buzzRuntime.id], "method");
+ return;
+ }
+ setDetailConfigBackTarget("method");
+ setSelectedRuntimeId(buzzRuntime.id);
+ setStage("detail");
+ return;
+ }
+ }
+
+ setStage("list");
+ }
+
+ function openRuntime(runtimeId: string) {
+ setLocalDirection("forward");
+ const runtime = runtimeProviders.items.find(
+ (item) => item.id === runtimeId,
+ );
+ if (readinessConfirmed && runtime && runtimeIsReadyForOnboarding(runtime)) {
+ actions.next([runtime.id]);
+ return;
+ }
+ setDetailConfigBackTarget("list");
+ setSelectedRuntimeId(runtimeId);
+ setStage("detail");
+ }
+
+ const transitionKey =
+ stage === "method"
+ ? "setup-method"
+ : stage === "list"
+ ? `setup-list-${method ?? "none"}`
+ : `setup-detail-${selectedRuntimeId ?? "none"}`;
return (
-
-
-
- actions.next(readyRuntimeIds)}
- type="button"
- >
- Next
-
- actions.next([])}
- type="button"
- variant="ghost"
- >
- Skip for now
-
-
+ {stage === "method" ? (
+ <>
+
+
+ actions.next([])}
+ type="button"
+ variant="ghost"
+ >
+ Set up later
+
+
+ >
+ ) : stage === "list" && method ? (
+ <>
+
+
+ actions.next([])}
+ type="button"
+ variant="ghost"
+ >
+ Set up later
+
+
+ >
+ ) : selectedRuntime && !selectedRuntimeIsReady ? (
+
+ ) : null}
);
}
@@ -792,13 +1176,22 @@ function SetupStepContent({
export function SetupStep({
actions,
direction,
+ initialMethod,
+ onInitialListBack,
+ onBackActionChange,
+ onMethodChange,
onReadyRuntimeIdsChange,
}: SetupStepProps) {
- const state = useSetupStepState();
+ const { onRefresh, state } = useSetupStepState();
return (
diff --git a/desktop/src/features/onboarding/ui/harnessConnectionOptions.test.mjs b/desktop/src/features/onboarding/ui/harnessConnectionOptions.test.mjs
new file mode 100644
index 00000000000..452044b8349
--- /dev/null
+++ b/desktop/src/features/onboarding/ui/harnessConnectionOptions.test.mjs
@@ -0,0 +1,38 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ getRuntimesForConnectionMethod,
+ runtimeSupportsConnectionMethod,
+} from "./harnessConnectionOptions.ts";
+
+const runtimes = [
+ { id: "claude" },
+ { id: "codex" },
+ { id: "buzz-agent" },
+ { id: "goose" },
+ { id: "cursor" },
+ { id: "openclaw" },
+ { id: "custom" },
+];
+
+test("subscription and API choices expose the prototype catalog groups", () => {
+ assert.deepEqual(
+ getRuntimesForConnectionMethod(runtimes, "subscription").map(
+ ({ id }) => id,
+ ),
+ ["claude", "codex", "cursor"],
+ );
+ assert.deepEqual(
+ getRuntimesForConnectionMethod(runtimes, "api").map(({ id }) => id),
+ ["buzz-agent", "goose", "openclaw"],
+ );
+});
+
+test("custom harnesses are not assigned an onboarding connection method", () => {
+ assert.equal(
+ runtimeSupportsConnectionMethod("custom", "subscription"),
+ false,
+ );
+ assert.equal(runtimeSupportsConnectionMethod("custom", "api"), false);
+});
diff --git a/desktop/src/features/onboarding/ui/harnessConnectionOptions.ts b/desktop/src/features/onboarding/ui/harnessConnectionOptions.ts
new file mode 100644
index 00000000000..4b5e5513e61
--- /dev/null
+++ b/desktop/src/features/onboarding/ui/harnessConnectionOptions.ts
@@ -0,0 +1,48 @@
+import type { AcpRuntimeCatalogEntry } from "@/shared/api/types";
+
+export type HarnessConnectionMethod = "subscription" | "api";
+
+const SUBSCRIPTION_RUNTIME_IDS = new Set([
+ "claude",
+ "codex",
+ "cursor",
+ "devin",
+ "amp",
+]);
+
+const API_RUNTIME_IDS = new Set([
+ "buzz-agent",
+ "goose",
+ "omp",
+ "grok",
+ "opencode",
+ "kimi",
+ "hermes",
+ "openclaw",
+]);
+
+export function runtimeSupportsConnectionMethod(
+ runtimeId: string,
+ method: HarnessConnectionMethod,
+) {
+ return (
+ method === "subscription" ? SUBSCRIPTION_RUNTIME_IDS : API_RUNTIME_IDS
+ ).has(runtimeId);
+}
+
+export function runtimeUnavailableDescription(
+ runtime: AcpRuntimeCatalogEntry,
+): string {
+ return runtime.availability === "adapter_outdated"
+ ? `${runtime.label} needs an ACP adapter update.`
+ : `${runtime.label} is not detected on this computer.`;
+}
+
+export function getRuntimesForConnectionMethod(
+ runtimes: readonly AcpRuntimeCatalogEntry[],
+ method: HarnessConnectionMethod,
+) {
+ return runtimes.filter((runtime) =>
+ runtimeSupportsConnectionMethod(runtime.id, method),
+ );
+}
diff --git a/desktop/src/features/onboarding/ui/onboardingCardStyles.ts b/desktop/src/features/onboarding/ui/onboardingCardStyles.ts
new file mode 100644
index 00000000000..92e245bbd9b
--- /dev/null
+++ b/desktop/src/features/onboarding/ui/onboardingCardStyles.ts
@@ -0,0 +1,7 @@
+export const ONBOARDING_CARD_INPUT_CLASS =
+ "h-12 rounded-xl border-[#e2e2e2] bg-[#f9f9f9] px-4 text-[oklch(0.22213_0_0)] shadow-none transition-[border-color,box-shadow] duration-200 ease-out placeholder:text-[oklch(0.6901_0_0)] placeholder:opacity-60 hover:border-[#d6d6d6] focus-visible:border-[#e2e2e2] focus-visible:ring-0 focus-visible:shadow-[0_0_0_3px_white,0_0_0_6px_rgba(0,0,0,0.06)] motion-reduce:transition-none";
+
+/** Quiet card actions share the same translucent neutral surface as Back. */
+export const ONBOARDING_CARD_NEUTRAL_SURFACE_CLASS = "bg-[#e2e2e2]/30";
+
+export const ONBOARDING_CARD_SECONDARY_CTA_CLASS = `${ONBOARDING_CARD_NEUTRAL_SURFACE_CLASS} text-foreground hover:bg-[#e2e2e2]/50 hover:text-foreground`;
diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs
index 221702ebb2a..5675798b508 100644
--- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs
+++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs
@@ -12,11 +12,13 @@ function runtime(id, availability, status) {
return { id, availability, authStatus: { status } };
}
-test("all bundled harnesses are visible in onboarding", () => {
+test("the V3 harness catalog is visible in onboarding", () => {
assert.equal(runtimeIsVisibleInOnboarding("claude"), true);
assert.equal(runtimeIsVisibleInOnboarding("codex"), true);
assert.equal(runtimeIsVisibleInOnboarding("goose"), true);
assert.equal(runtimeIsVisibleInOnboarding("buzz-agent"), true);
+ assert.equal(runtimeIsVisibleInOnboarding("cursor"), true);
+ assert.equal(runtimeIsVisibleInOnboarding("openclaw"), true);
assert.equal(runtimeIsVisibleInOnboarding("custom"), false);
});
diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts
index cd491dfcc5a..74e24d5f6e1 100644
--- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts
+++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts
@@ -5,6 +5,15 @@ export const ONBOARDING_RUNTIME_ORDER = [
"codex",
"goose",
"buzz-agent",
+ "cursor",
+ "devin",
+ "omp",
+ "grok",
+ "opencode",
+ "kimi",
+ "amp",
+ "hermes",
+ "openclaw",
];
const VISIBLE_ONBOARDING_RUNTIME_IDS = new Set(
diff --git a/desktop/src/features/onboarding/ui/types.ts b/desktop/src/features/onboarding/ui/types.ts
index 5216bfefa4d..af8cf0d85b4 100644
--- a/desktop/src/features/onboarding/ui/types.ts
+++ b/desktop/src/features/onboarding/ui/types.ts
@@ -42,6 +42,7 @@ export type ProfileStepAvatarState = {
export type ProfileStepState = {
avatar: ProfileStepAvatarState;
+ isReadyToSubmit: boolean;
isUploadingAvatar: boolean;
isSaving: boolean;
name: ProfileStepNameState;
@@ -62,8 +63,10 @@ export type ProfileStepActions = {
export type SetupStepActions = {
back: () => void;
- next: (readyRuntimeIds: readonly string[]) => void;
- navigateToAgentSettings?: () => void;
+ next: (
+ readyRuntimeIds: readonly string[],
+ configBackTarget?: "method" | "list",
+ ) => void;
};
export type DefaultConfigDraft = {
@@ -78,10 +81,12 @@ export type DefaultConfigStepActions = {
complete: () => void;
discardDraft: () => void;
updateDraft: (draft: DefaultConfigDraft) => void;
+ useDifferentHarness?: () => void;
};
export type SetupStepRuntimeState = {
errorMessage: string | null;
+ hasForcedCheckStarted: boolean;
isChecking: boolean;
items: AcpRuntimeCatalogEntry[];
};
diff --git a/desktop/src/features/profile/ui/AnimatedAvatarCameraControls.tsx b/desktop/src/features/profile/ui/AnimatedAvatarCameraControls.tsx
index 4b91385cc8a..a647ccba4f7 100644
--- a/desktop/src/features/profile/ui/AnimatedAvatarCameraControls.tsx
+++ b/desktop/src/features/profile/ui/AnimatedAvatarCameraControls.tsx
@@ -23,6 +23,7 @@ type AnimatedAvatarCameraControlsProps = {
onRetry?: () => void;
onSelectSource: (source: CameraSource) => void;
showCameraPicker: boolean;
+ stackCameraOptions: boolean;
testIdPrefix: string;
};
@@ -39,10 +40,68 @@ export function AnimatedAvatarCameraControls({
onRetry,
onSelectSource,
showCameraPicker,
+ stackCameraOptions,
testIdPrefix,
}: AnimatedAvatarCameraControlsProps) {
+ const showCameraAction = Boolean(onRetry || isLive);
+ const usesAnimatedStack = stackCameraOptions && showCameraPicker && !helpText;
+ const reserveCameraAction = !stackCameraOptions || showCameraAction;
+ const cameraAction = reserveCameraAction ? (
+
+ {onRetry ? (
+
+ Try camera again
+
+ ) : isLive ? (
+
+
+
+ Capture {RECORD_SECONDS} sec video
+
+
+ ) : null}
+
+ ) : null;
+
return (
-
+
{showCameraPicker ? (
) : null}
@@ -58,45 +118,10 @@ export function AnimatedAvatarCameraControls({
{helpText}
) : null}
-
- {onRetry ? (
-
- Try camera again
-
- ) : isLive ? (
-
-
-
- Capture {RECORD_SECONDS} sec video
-
-
- ) : null}
-
+ {usesAnimatedStack ? (
+
{cameraAction}
+ ) : null}
+ {!usesAnimatedStack && reserveCameraAction ? cameraAction : null}
);
}
diff --git a/desktop/src/features/profile/ui/AnimatedAvatarCameraPicker.tsx b/desktop/src/features/profile/ui/AnimatedAvatarCameraPicker.tsx
index 08cc858cc5c..ed405ada3e5 100644
--- a/desktop/src/features/profile/ui/AnimatedAvatarCameraPicker.tsx
+++ b/desktop/src/features/profile/ui/AnimatedAvatarCameraPicker.tsx
@@ -9,6 +9,7 @@ type AnimatedAvatarCameraPickerProps = {
disabled?: boolean;
iphoneDisabled: boolean;
onSelectSource: (source: CameraSource) => void;
+ stacked?: boolean;
testIdPrefix: string;
};
@@ -18,10 +19,16 @@ export function AnimatedAvatarCameraPicker({
disabled = false,
iphoneDisabled,
onSelectSource,
+ stacked = false,
testIdPrefix,
}: AnimatedAvatarCameraPickerProps) {
return (
-
+
{[
{
disabled: iphoneDisabled,
@@ -43,7 +50,8 @@ export function AnimatedAvatarCameraPicker({
("idle");
const [errorMessage, setErrorMessage] = React.useState(null);
@@ -797,14 +799,18 @@ export function AnimatedAvatarCapture({
return (
@@ -939,6 +945,7 @@ export function AnimatedAvatarCapture({
}
onSelectSource={selectCameraSource}
showCameraPicker={showCameraPicker}
+ stackCameraOptions={stackCameraOptions}
testIdPrefix={testIdPrefix}
/>
) : usePortal && inlineCaptureHelpText ? (
@@ -989,7 +996,8 @@ export function AnimatedAvatarCapture({
setCustomValue(nextValue);
}}
saturation={customSaturation}
- className="h-[504px]"
+ className={compactColorPicker ? undefined : "h-[504px]"}
+ compact={compactColorPicker}
testIdPrefix={`${testIdPrefix}-animated`}
value={customValue}
visible={isCustomPickerVisible}
diff --git a/desktop/src/features/profile/ui/AnimatedAvatarCapture.types.ts b/desktop/src/features/profile/ui/AnimatedAvatarCapture.types.ts
index d443e98da74..b3e14dcc5b0 100644
--- a/desktop/src/features/profile/ui/AnimatedAvatarCapture.types.ts
+++ b/desktop/src/features/profile/ui/AnimatedAvatarCapture.types.ts
@@ -11,4 +11,6 @@ export type AnimatedAvatarCaptureProps = {
showApplyButton?: boolean;
autoStartCamera?: boolean;
compactReview?: boolean;
+ compactColorPicker?: boolean;
+ stackCameraOptions?: boolean;
};
diff --git a/desktop/src/features/profile/ui/AnimatedAvatarControls.tsx b/desktop/src/features/profile/ui/AnimatedAvatarControls.tsx
index 662893c4094..8400d522985 100644
--- a/desktop/src/features/profile/ui/AnimatedAvatarControls.tsx
+++ b/desktop/src/features/profile/ui/AnimatedAvatarControls.tsx
@@ -403,7 +403,7 @@ export function AvatarFilmstripPicker({
aria-valuemax={maxFrameIndex}
aria-valuemin={0}
aria-valuenow={safeSelectedFrame}
- className="relative h-12 min-w-0 touch-none rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ className="relative h-16 min-w-0 touch-none rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid={`${testIdPrefix}-animated-poster-scrubber`}
onKeyDown={(event) => {
if (event.key === "ArrowLeft") {
@@ -434,45 +434,46 @@ export function AvatarFilmstripPicker({
}
selectFromClientX(event.clientX);
}}
- ref={stripRef}
role="slider"
tabIndex={disabled ? -1 : 0}
>
-
- {frames.length === 0 ? (
-
-
-
- ) : (
-
- {frames.map((frame, index) => (
-
+
+ {frames.length === 0 ? (
+
+
- ))}
-
- )}
+
+ ) : (
+
+ {frames.map((frame, index) => (
+
+ ))}
+
+ )}
+
+ {frames.length > 0 ? (
+
+ ) : null}
- {frames.length > 0 ? (
-
- ) : null}
{helpText ? (
void;
testIdPrefix: string;
className?: string;
+ compact?: boolean;
};
/**
@@ -54,6 +55,7 @@ export function AvatarCustomColorPanel({
onCommit,
testIdPrefix,
className,
+ compact = false,
}: AvatarCustomColorPanelProps) {
const hueDragUserSelectRef = React.useRef(null);
@@ -136,7 +138,8 @@ export function AvatarCustomColorPanel({
{
if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
@@ -265,7 +271,10 @@ export function AvatarCustomColorPanel({
>
{
if (mode !== "emoji") return;
@@ -504,9 +526,11 @@ export function ProfileAvatarEditor({
{modeTabsContent}
@@ -580,31 +606,50 @@ export function ProfileAvatarEditor({
{mode === "image" ? (
-
+
) : (
-
+
@@ -838,7 +910,11 @@ export function ProfileAvatarEditor({
aria-pressed={isSelected}
className={cn(
"relative scroll-mb-52 rounded-full border border-border transition-transform duration-200 ease-out hover:scale-[1.15] focus-visible:scale-[1.15] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
- isOnboardingModal ? "h-7 w-7" : "h-10 w-10",
+ isOnboardingInline
+ ? "h-5 w-5"
+ : isOnboardingModal
+ ? "h-7 w-7"
+ : "h-10 w-10",
isCustomSwatch &&
!selectedEmoji &&
"cursor-not-allowed opacity-45 hover:scale-100 focus-visible:scale-100",
@@ -864,7 +940,9 @@ export function ProfileAvatarEditor({
void;
onAnimatedPreviewCaptionChange?: (caption: string | null) => void;
presentation?: AvatarEditorPresentation;
+ /** Uses a shorter spectrum panel so the picker fits in compact hosts. */
+ compactCustomColorPicker?: boolean;
+ /** Places the animated-avatar camera choices in one vertical column. */
+ stackAnimatedCameraOptions?: boolean;
};
diff --git a/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts b/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts
index 03b5e38026d..bee2d330f5c 100644
--- a/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts
+++ b/desktop/src/features/profile/ui/ProfileAvatarEditor.utils.ts
@@ -72,7 +72,6 @@ const EMOJI_MART_SHADOW_CSS = `
#root {
--padding: var(--buzz-emoji-picker-padding, 16px);
- --buzz-emoji-picker-search-control-height: 48px;
--sidebar-width: 0px;
display: flex;
flex-direction: column;
@@ -128,6 +127,16 @@ const EMOJI_MART_SHADOW_CSS = `
display: none;
}
+ :host([data-buzz-onboarding-inline]) .category .sticky {
+ background-color: rgb(var(--em-rgb-background));
+ display: block;
+ z-index: 5;
+ }
+
+ :host([data-buzz-onboarding-inline]) .scroll {
+ padding-top: 0;
+ }
+
/* Match the app's member-search controls: a distinct resting surface and
* border make both the emoji search and its adjacent skin-tone control easy
* to find before either receives focus. */
@@ -138,8 +147,8 @@ const EMOJI_MART_SHADOW_CSS = `
}
.search input[type="search"] {
- border-radius: 12px;
- height: var(--buzz-emoji-picker-search-control-height);
+ border-radius: 8px;
+ height: var(--buzz-emoji-picker-search-control-height, 48px);
padding-bottom: 0;
padding-top: 0;
}
@@ -149,20 +158,45 @@ const EMOJI_MART_SHADOW_CSS = `
}
.search + .flex {
- border-radius: 12px;
+ border-radius: 8px;
flex: 0 0 auto;
- height: var(--buzz-emoji-picker-search-control-height) !important;
+ height: var(--buzz-emoji-picker-search-control-height, 48px) !important;
margin-left: 8px;
- width: var(--buzz-emoji-picker-search-control-height) !important;
+ width: var(--buzz-emoji-picker-search-control-height, 48px) !important;
}
.skin-tone-button {
background-color: transparent !important;
border: 0 !important;
- border-radius: 8px;
+ border-radius: 4px;
box-shadow: none !important;
- height: calc(var(--buzz-emoji-picker-search-control-height) - 8px) !important;
- width: calc(var(--buzz-emoji-picker-search-control-height) - 8px) !important;
+ height: calc(var(--buzz-emoji-picker-search-control-height, 48px) - 8px) !important;
+ width: calc(var(--buzz-emoji-picker-search-control-height, 48px) - 8px) !important;
+ }
+
+ :host([data-buzz-onboarding-inline]) #root > .padding-lr:not(.scroll) {
+ background-color: rgb(var(--em-rgb-background));
+ padding-bottom: 8px;
+ padding-top: 8px;
+ position: relative;
+ z-index: 6;
+ }
+
+ :host([data-buzz-onboarding-inline])
+ #root
+ > .padding-lr:not(.scroll)
+ > div
+ > .spacer {
+ display: none;
+ }
+
+ :host([data-buzz-onboarding-inline]) #nav {
+ display: none;
+ }
+
+ :host([data-buzz-onboarding-inline]) .menu {
+ background-color: rgb(var(--em-rgb-background));
+ z-index: 7;
}
.skin-tone-button[aria-selected] {
@@ -588,6 +622,7 @@ function installEmojiMartWheelScroll(shadowRoot: ShadowRoot) {
export function useEmojiMartStyles(
containerRef: React.RefObject,
enabled: boolean,
+ onboardingInline = false,
) {
React.useEffect(() => {
if (!enabled) {
@@ -596,6 +631,7 @@ export function useEmojiMartStyles(
let animationFrame = 0;
let removeWheelScroll: (() => void) | null = null;
+ let styledHost: Element | null = null;
const installEmojiMartStyles = () => {
const host = containerRef.current?.querySelector("em-emoji-picker");
@@ -606,6 +642,9 @@ export function useEmojiMartStyles(
return;
}
+ styledHost = host;
+ host.toggleAttribute("data-buzz-onboarding-inline", onboardingInline);
+
if (!shadowRoot.querySelector("#buzz-emoji-mart-style")) {
const style = document.createElement("style");
style.id = "buzz-emoji-mart-style";
@@ -621,8 +660,9 @@ export function useEmojiMartStyles(
return () => {
window.cancelAnimationFrame(animationFrame);
removeWheelScroll?.();
+ styledHost?.removeAttribute("data-buzz-onboarding-inline");
};
- }, [containerRef, enabled]);
+ }, [containerRef, enabled, onboardingInline]);
}
export function useEmojiMartThemeVars() {
diff --git a/desktop/src/features/profile/ui/ProfileAvatarModeTabs.tsx b/desktop/src/features/profile/ui/ProfileAvatarModeTabs.tsx
index 316fa54921e..0145b8f878d 100644
--- a/desktop/src/features/profile/ui/ProfileAvatarModeTabs.tsx
+++ b/desktop/src/features/profile/ui/ProfileAvatarModeTabs.tsx
@@ -5,6 +5,7 @@ import type {
AvatarMode,
} from "@/features/profile/ui/ProfileAvatarEditor.types";
import { cn } from "@/shared/lib/cn";
+import { SegmentedControl } from "@/shared/ui/segmented-control";
import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
const MODE_TAB_ORDER: AvatarMode[] = ["image", "emoji", "animated"];
@@ -13,6 +14,10 @@ const MODE_TAB_LABELS: Record = {
emoji: "Emoji",
image: "Image",
};
+const MODE_SEGMENT_OPTIONS = MODE_TAB_ORDER.map((value) => ({
+ label: MODE_TAB_LABELS[value],
+ value,
+}));
type ProfileAvatarModeTabsProps = {
disabled: boolean;
@@ -30,7 +35,20 @@ export function ProfileAvatarModeTabs({
portalContainer,
}: ProfileAvatarModeTabsProps) {
const isOnboardingModal = presentation === "onboarding-modal";
- const tabs = (
+ const isOnboardingInline = presentation === "onboarding-inline";
+ const tabs = isOnboardingInline ? (
+
+ ) : (
{
@@ -49,7 +67,8 @@ export function ProfileAvatarModeTabs({
;
+ /** Reject one `get_identity` call after this many successful reads. */
+ identityReadErrorAfter?: { message: string; successfulReads: number };
// When true, `get_identity` returns `lost: true` until `persist_current_identity`
// or `import_identity` is called. Drives the identity-lost recovery UX in tests.
identityLost?: boolean;
@@ -1594,6 +1598,10 @@ declare global {
__BUZZ_E2E_HOLD_USERS_BATCH__?: (hold: boolean) => number;
/** Number of `get_users_batch` calls currently held. */
__BUZZ_E2E_USERS_BATCH_PENDING__?: () => number;
+ /** Release every `get_profile` response held by `deferProfileReads`. */
+ __BUZZ_E2E_RELEASE_PROFILE_READS__?: () => number;
+ /** Number of `get_profile` responses currently held. */
+ __BUZZ_E2E_PROFILE_READS_PENDING__?: () => number;
/** Uploads that passed mock-native registration and began relay work. */
__BUZZ_E2E_LINK_PREVIEW_UPLOAD_STARTS__?: number;
/** Hold renderer-owned media fetches until their cancellation command. */
@@ -1697,6 +1705,8 @@ const STARTER_WELCOME_CHANNEL_NAME = "welcome-everyone";
let mockIdentityLostCleared = false;
// Same pattern for `mock.identityLocked`.
let mockIdentityLockedCleared = false;
+let identityReadCount = 0;
+let identityReadErrorConsumed = false;
// ── get_event defer/release seam ────────────────────────────────────────────
// When `window.__BUZZ_E2E_DEFER_GET_EVENT__` is set to a target event ID,
@@ -1715,6 +1725,13 @@ let deferredGetEventQueue: DeferredGetEvent[] = [];
let deferredLinkPreviewMetadataQueue: Array<() => void> = [];
let deferredLinkPreviewUploadQueue: Array<() => void> = [];
let deferredThreadRepliesQueue: Array<() => void> = [];
+type DeferredProfileRead = {
+ reject: (reason: unknown) => void;
+ resolve: (value: unknown) => void;
+ run: () => Promise;
+};
+let deferredProfileReadQueue: DeferredProfileRead[] = [];
+let profileReadsReleased = false;
// ── get_users_batch hold seam ───────────────────────────────────────────────
// Toggled at runtime by `__BUZZ_E2E_HOLD_USERS_BATCH__(hold)` rather than fixed
// at boot by a mock-config flag, because a mention-identity spec needs both
@@ -6739,8 +6756,15 @@ async function handleGetChannels(
};
}
-async function handleGetProfile(config: E2eConfig | undefined) {
+async function runGetProfile(config: E2eConfig | undefined) {
const identity = getIdentity(config);
+ const profileReadDelayMs = config?.mock?.profileReadDelayMs ?? 0;
+ if (profileReadDelayMs > 0) {
+ await new Promise((resolve) => {
+ window.setTimeout(resolve, profileReadDelayMs);
+ });
+ }
+
const forcedHasProfileEvent = config?.mock?.profileHasEvent;
if (forcedHasProfileEvent !== undefined) {
return {
@@ -6749,13 +6773,6 @@ async function handleGetProfile(config: E2eConfig | undefined) {
};
}
if (!identity) {
- const profileReadDelayMs = config?.mock?.profileReadDelayMs ?? 0;
- if (profileReadDelayMs > 0) {
- await new Promise((resolve) => {
- window.setTimeout(resolve, profileReadDelayMs);
- });
- }
-
const profileReadError = config?.mock?.profileReadError;
if (profileReadError) {
throw new Error(profileReadError);
@@ -6791,6 +6808,20 @@ async function handleGetProfile(config: E2eConfig | undefined) {
};
}
+async function handleGetProfile(config: E2eConfig | undefined) {
+ if (!config?.mock?.deferProfileReads || profileReadsReleased) {
+ return runGetProfile(config);
+ }
+
+ return new Promise((resolve, reject) => {
+ deferredProfileReadQueue.push({
+ resolve,
+ reject,
+ run: () => runGetProfile(config),
+ });
+ });
+}
+
async function handleUpdateProfile(
args: {
displayName?: string;
@@ -11327,6 +11358,8 @@ export function maybeInstallE2eTauriMocks() {
deferredLinkPreviewMetadataQueue = [];
deferredLinkPreviewUploadQueue = [];
deferredThreadRepliesQueue = [];
+ deferredProfileReadQueue = [];
+ profileReadsReleased = false;
holdUsersBatch = false;
heldUsersBatchReleases = [];
cancelledMediaUploadIds = new Set();
@@ -11354,6 +11387,16 @@ export function maybeInstallE2eTauriMocks() {
};
window.__BUZZ_E2E_THREAD_REPLIES_PENDING__ = () =>
deferredThreadRepliesQueue.length;
+ window.__BUZZ_E2E_RELEASE_PROFILE_READS__ = () => {
+ profileReadsReleased = true;
+ const queued = deferredProfileReadQueue.splice(0);
+ for (const deferred of queued) {
+ void deferred.run().then(deferred.resolve, deferred.reject);
+ }
+ return queued.length;
+ };
+ window.__BUZZ_E2E_PROFILE_READS_PENDING__ = () =>
+ deferredProfileReadQueue.length;
window.__BUZZ_E2E_HOLD_USERS_BATCH__ = (hold: boolean) => {
holdUsersBatch = hold;
// Releasing on the way out of the hold, not on the way in, is what lets a
@@ -12562,6 +12605,16 @@ export function maybeInstallE2eTauriMocks() {
}
}
case "get_identity": {
+ const identityReadError = activeConfig?.mock?.identityReadErrorAfter;
+ if (
+ identityReadError &&
+ !identityReadErrorConsumed &&
+ identityReadCount >= identityReadError.successfulReads
+ ) {
+ identityReadErrorConsumed = true;
+ throw new Error(identityReadError.message);
+ }
+ identityReadCount += 1;
const isLost =
!mockIdentityLostCleared && activeConfig?.mock?.identityLost === true;
const isLocked =
diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js
index 778f2caff31..1502a51d3ec 100644
--- a/desktop/tailwind.config.js
+++ b/desktop/tailwind.config.js
@@ -33,6 +33,11 @@ export default {
"calc(var(--buzz-type-rem) * 2.25)",
{ lineHeight: "1.3" },
],
+ // 22px at the 16px type rem — compact onboarding-card private key.
+ "nsec-key-card": [
+ "calc(var(--buzz-type-rem) * 1.375)",
+ { lineHeight: "1.3" },
+ ],
},
lineHeight: {
// Keep fixed Tailwind line-height utilities in the typography scale so
diff --git a/desktop/tests/e2e/animated-avatar.spec.ts b/desktop/tests/e2e/animated-avatar.spec.ts
index c738726f86e..a3a898a6cbe 100644
--- a/desktop/tests/e2e/animated-avatar.spec.ts
+++ b/desktop/tests/e2e/animated-avatar.spec.ts
@@ -193,6 +193,17 @@ test.describe("animated avatar", () => {
await expect(
page.getByTestId("profile-avatar-animated-poster-selector"),
).toBeVisible();
+ const selectorBox = await page
+ .getByTestId("profile-avatar-animated-poster-selector")
+ .boundingBox();
+ const posterStripBox = await page
+ .getByTestId("profile-avatar-animated-poster-scrubber")
+ .boundingBox();
+ if (!selectorBox || !posterStripBox) {
+ throw new Error("Animated avatar poster selector bounds are missing.");
+ }
+ expect(selectorBox.x - posterStripBox.x).toBeGreaterThanOrEqual(8);
+ expect(selectorBox.y - posterStripBox.y).toBeGreaterThanOrEqual(8);
await expect(
page.getByTestId("profile-avatar-animated-review-help"),
).toHaveText("Pick the still shown before hover.");
diff --git a/desktop/tests/e2e/harness-management.spec.ts b/desktop/tests/e2e/harness-management.spec.ts
index c5b441ad21d..dbc7ab37d28 100644
--- a/desktop/tests/e2e/harness-management.spec.ts
+++ b/desktop/tests/e2e/harness-management.spec.ts
@@ -21,7 +21,6 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
-import { passThroughBackupStep } from "../helpers/onboarding";
// ── Shared catalog fixtures ───────────────────────────────────────────────────
@@ -618,73 +617,3 @@ test("not-ready custom harness row shows status, no install action", async ({
page.getByTestId("doctor-runtime-ready-my-custom-agent"),
).toHaveCount(0);
});
-
-// ── F8: onboarding navigate-after-complete ────────────────────────────────────
-//
-// Verifies the parent-owned route intent introduced in B-8:
-// 1. User reaches the machine-onboarding setup page.
-// 2. Clicks "More harnesses" (onboarding-setup-more-harnesses).
-// 3. App completes onboarding and immediately navigates to Settings → Agents.
-//
-// This test exercises the real App.tsx effect that gates router.navigate() on
-// machine.stage === "ready", which the pure-logic tests in
-// postOnboardingNav.test.mjs cannot cover (they simulate the predicate, not
-// the real render path).
-
-test("onboarding setup More-harnesses click navigates to Settings → Agents", async ({
- page,
-}) => {
- // Start with a fresh machine (no machine-onboarding-complete flag).
- // skipCommunitySeed: true so the user goes through machine onboarding.
- // skipOnboardingSeed: true so the community/identity banner doesn't appear.
- await installMockBridge(page, undefined, {
- skipCommunitySeed: true,
- skipOnboardingSeed: true,
- });
- // Seed a community stamped with a *foreign* pubkey. This is the only shape
- // that satisfies both preconditions of this test at once:
- // - machine onboarding must still run, so the community must NOT vouch for
- // the active identity (migrateMachineOnboardingCompletion only accepts a
- // community whose recorded pubkey matches — see machineOnboarding.ts:70).
- // - after onboarding completes, useCommunityInit must NOT report
- // needsSetup, or App.tsx:499 renders WelcomeSetup instead of the router
- // and the navigation lands on a screen that has no settings tree.
- // The default seed vouches (it uses the active pubkey) and skipping it
- // entirely leaves zero communities, so neither default gets there.
- await page.addInitScript(() => {
- const communityId = "e2e-default-community";
- window.localStorage.setItem(
- "buzz-communities",
- JSON.stringify([
- {
- id: communityId,
- name: "E2E Test",
- relayUrl: "ws://127.0.0.1:7777",
- pubkey: "f".repeat(64),
- addedAt: new Date().toISOString(),
- },
- ]),
- );
- window.localStorage.setItem("buzz-active-community-id", communityId);
- });
- await page.goto("/");
-
- // Reach setup by creating a new identity key and continuing past the
- // created-key page without opening the optional backup options.
- await page.getByRole("button", { name: "Create a new identity key" }).click();
- await passThroughBackupStep(page);
-
- // Now on the setup page.
- await expect(
- page.getByRole("heading", { name: "Set up your agent harnesses" }),
- ).toBeVisible({ timeout: 10_000 });
-
- // Click the "More harnesses" link — fires navigateToAgentSettings.
- await page.getByTestId("onboarding-setup-more-harnesses").click();
-
- // After onboarding completes + router mounts, the app must land on
- // Settings → Agents (consolidated harnesses section visible).
- await expect(page.getByTestId("settings-harnesses")).toBeVisible({
- timeout: 15_000,
- });
-});
diff --git a/desktop/tests/e2e/identity-key-help.spec.ts b/desktop/tests/e2e/identity-key-help.spec.ts
index d204d556041..3873366a433 100644
--- a/desktop/tests/e2e/identity-key-help.spec.ts
+++ b/desktop/tests/e2e/identity-key-help.spec.ts
@@ -32,13 +32,8 @@ test("identity key help explains the first-run choice", async ({ page }) => {
await expect(
dialog.getByRole("heading", { name: "What’s an identity key?" }),
).toBeVisible();
- await expect(dialog).toHaveClass(/shadow-none/);
- await expect(page.getByTestId("dialog-overlay")).toHaveCSS(
- "background-color",
- "rgba(0, 0, 0, 0)",
- );
- const dialogWrapper = dialog.locator("..");
- await expect(dialogWrapper).toHaveCSS("overflow-x", "hidden");
+ await expect(dialog).toHaveClass(/w-full/);
+ await expect(page.getByTestId("dialog-overlay")).toHaveCount(0);
const dialogBounds = await dialog.boundingBox();
expect(dialogBounds).not.toBeNull();
expect(dialogBounds?.x).toBeGreaterThanOrEqual(0);
@@ -46,7 +41,7 @@ test("identity key help explains the first-run choice", async ({ page }) => {
(dialogBounds?.x ?? 0) + (dialogBounds?.width ?? 0),
).toBeLessThanOrEqual(720);
- await page.keyboard.press("Escape");
+ await page.getByTestId("onboarding-back").click();
await expect(dialog).not.toBeVisible();
await expect(trigger).toHaveCSS("opacity", "1");
diff --git a/desktop/tests/e2e/identity-lost.spec.ts b/desktop/tests/e2e/identity-lost.spec.ts
index 2ab41cb52d3..26979c10b66 100644
--- a/desktop/tests/e2e/identity-lost.spec.ts
+++ b/desktop/tests/e2e/identity-lost.spec.ts
@@ -2,6 +2,7 @@ import { hexToBytes } from "@noble/hashes/utils.js";
import { expect, test } from "@playwright/test";
import { nsecEncode } from "nostr-tools/nip19";
+import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
test("normal first launch uses the already-persisted identity", async ({
@@ -24,10 +25,11 @@ test("normal first launch uses the already-persisted identity", async ({
page.getByRole("button", { name: "Create a new identity key" }),
).toHaveCSS("background-color", "rgb(23, 23, 23)");
await page.getByRole("button", { name: "Create a new identity key" }).click();
+ await page.getByRole("button", { name: "Create my private key" }).click();
await expect(
page.getByRole("heading", {
- name: "Your unique identity key has been created",
+ name: "Your private identity key",
}),
).toBeVisible();
// Non-landing pages layer the dot grid over the chartreuse→light-blue gradient.
@@ -111,12 +113,14 @@ test("lost boot offers phone recovery with a single-use QR", async ({
await expect(page.getByTestId("identity-recovery-pairing")).toBeVisible();
await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
await expect(
- page.getByText("Scan this code with a signed-in Buzz phone."),
+ page.getByText(
+ "Scan this code with a device where you’re currently signed in to Buzz.",
+ ),
).toBeVisible();
await expect(
page.getByText("On your phone, open Settings → Send identity to desktop."),
- ).toBeVisible();
- await page.waitForTimeout(1_000); // Let the onboarding entrance motion settle.
+ ).toHaveCount(0);
+ await waitForAnimations(page);
await page.screenshot({
path: testInfo.outputPath("desktop-phone-recovery-qr.png"),
fullPage: true,
@@ -169,20 +173,39 @@ test("phone recovery uses the desktop pairing card semantics", async ({
await page.getByTestId("nostr-import-phone-link").click();
const card = page.getByTestId("identity-recovery-pairing");
+ const stage = page.getByTestId("identity-recovery-stage");
const qrContainer = card.getByTestId("identity-recovery-qr-container");
const qrCode = card.getByTestId("identity-recovery-qr");
const copyButton = card.getByTestId("copy-identity-recovery-code");
await expect(qrCode).toBeVisible();
+ await expect(card).toHaveCSS("border-top-width", "0px");
+ await expect(qrContainer).toHaveCSS("border-top-width", "0px");
+ await expect(card).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
+ await expect(qrContainer).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(qrCode).toHaveAttribute("data-qr-matrix-size", "57");
await expect(qrCode.locator("[data-qr-finder-pattern]")).toHaveCount(3);
await expect(qrCode.locator(".buzz-qr-cell-reveal").first()).toHaveCSS(
"animation-name",
"buzz-qr-cell-reveal",
);
- const qrBox = await qrContainer.boundingBox();
- const copyBox = await copyButton.boundingBox();
+ await waitForAnimations(page);
+ const [stageBox, cardBox, qrBox, copyBox] = await Promise.all([
+ stage.boundingBox(),
+ card.boundingBox(),
+ qrContainer.boundingBox(),
+ copyButton.boundingBox(),
+ ]);
+ expect(stageBox).not.toBeNull();
+ expect(cardBox).not.toBeNull();
expect(qrBox).not.toBeNull();
expect(copyBox).not.toBeNull();
+ expect(
+ Math.abs(
+ (cardBox?.y ?? 0) +
+ (cardBox?.height ?? 0) / 2 -
+ ((stageBox?.y ?? 0) + (stageBox?.height ?? 0) / 2),
+ ),
+ ).toBeLessThan(1);
expect(Math.abs((copyBox?.x ?? 0) - (qrBox?.x ?? 0))).toBeLessThan(1);
expect(Math.abs((copyBox?.width ?? 0) - (qrBox?.width ?? 0))).toBeLessThan(1);
@@ -204,9 +227,7 @@ test("phone recovery uses the desktop pairing card semantics", async ({
"This gives this desktop permanent access to your Buzz identity. Only continue if you trust it.",
),
).toBeVisible();
- await expect(
- card.getByText(/On your phone, open Settings/),
- ).not.toBeVisible();
+ await expect(card.getByText(/On your phone, open Settings/)).toHaveCount(0);
await expect(card.getByTestId("identity-recovery-sas")).toHaveText("123 456");
await expect(card.getByTestId("confirm-identity-recovery-sas")).toHaveText(
"Codes match",
@@ -282,12 +303,12 @@ test("phone recovery continues to harness setup without creating or restarting",
});
await expect(
- page.getByRole("heading", { name: "Set up your agent harnesses" }),
+ page.getByRole("heading", { name: "Connect your AI provider" }),
).toBeVisible();
await expect(page.getByTestId("relaunch-required")).toHaveCount(0);
await expect(
page.getByRole("heading", {
- name: "Your unique identity key has been created",
+ name: "Your private identity key",
}),
).toHaveCount(0);
});
diff --git a/desktop/tests/e2e/key-import-reveal.spec.ts b/desktop/tests/e2e/key-import-reveal.spec.ts
index cd05f71e768..a1bee7aaa23 100644
--- a/desktop/tests/e2e/key-import-reveal.spec.ts
+++ b/desktop/tests/e2e/key-import-reveal.spec.ts
@@ -6,10 +6,6 @@ import { installMockBridge } from "../helpers/bridge";
const SAMPLE_NSEC =
"nsec1u70xptkumvfc4k4hu0rc4fnzcexvw63zvq2ng9vmqujsaayhparqu8eju9";
-// --buzz-onboarding-backup-ink (#717106), the olive key ink shared with the
-// backup step.
-const BACKUP_INK = "rgb(113, 113, 6)";
-
test("key import masks the key with a reveal toggle", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await installMockBridge(page, undefined, {
@@ -23,12 +19,15 @@ test("key import masks the key with a reveal toggle", async ({ page }) => {
await expect(input).toBeVisible();
await waitForAnimations(page);
- // Masked by default; no toggle until there is input; key text uses the
- // shared backup ink.
+ // Masked by default; no toggle until there is input. The refreshed card
+ // keeps key text on the standard foreground token.
const toggle = page.getByTestId("nostr-import-reveal-toggle");
await expect(input).toHaveAttribute("type", "password");
await expect(toggle).toHaveCSS("opacity", "0");
- await expect(input).toHaveCSS("color", BACKUP_INK);
+ await expect(input).toHaveAttribute(
+ "class",
+ /text-\[oklch\(0\.22213_0_0\)\]/,
+ );
// The toggle is absolutely positioned: its appearance must not resize the
// input or shift the centered text.
diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts
index 2364b529552..83b8c1e4339 100644
--- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts
+++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts
@@ -37,10 +37,24 @@ function runtime(
async function navigateToSetupPage(
page: Parameters[0],
+ method: "subscription" | "api" | null = "subscription",
) {
await page.getByRole("button", { name: "Create a new identity key" }).click();
+ await page.getByRole("button", { name: "Create my private key" }).click();
await passThroughBackupStep(page);
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
+ if (method) {
+ await page.getByTestId(`onboarding-harness-method-${method}`).click();
+ }
+}
+
+async function chooseHarnessAndContinue(
+ page: Parameters[0],
+ runtimeId = "claude",
+) {
+ if (await page.getByTestId("onboarding-page-config").isVisible()) return;
+ await page.getByTestId(`onboarding-runtime-details-${runtimeId}`).click();
+ await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
}
async function readSavedRuntime(page: Parameters[0]) {
@@ -75,7 +89,13 @@ async function readGlobalConfigSetterCallCount(
});
}
-test("setup shows all bundled harnesses as detected", async ({ page }) => {
+test("setup filters the bundled harnesses by connection method", async ({
+ page,
+}) => {
+ const renderErrors: string[] = [];
+ page.on("console", (message) => {
+ if (message.type() === "error") renderErrors.push(message.text());
+ });
await installMockBridge(
page,
{
@@ -89,28 +109,179 @@ test("setup shows all bundled harnesses as detected", async ({ page }) => {
{ skipCommunitySeed: true, skipOnboardingSeed: true },
);
await page.goto("/");
- await navigateToSetupPage(page);
+ await navigateToSetupPage(page, null);
+
+ await expect(
+ page.getByRole("heading", { name: "Connect your AI provider" }),
+ ).toBeVisible();
+ await expect(
+ page.getByTestId("onboarding-harness-method-subscription"),
+ ).toContainText("Log in with a subscription");
+ await expect(page.getByTestId("onboarding-harness-method-api")).toContainText(
+ "Use an API key",
+ );
+ await page.getByTestId("onboarding-harness-method-subscription").click();
await expect(page.getByTestId("onboarding-runtime-claude")).toBeVisible();
await expect(page.getByTestId("onboarding-runtime-codex")).toBeVisible();
+ await expect(page.getByTestId("onboarding-runtime-goose")).toHaveCount(0);
+ await expect(page.getByTestId("onboarding-runtime-buzz-agent")).toHaveCount(
+ 0,
+ );
+ await page.getByTestId("onboarding-back").click();
+ await page.getByTestId("onboarding-harness-method-api").click();
+ await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Connect with an API key" }),
+ ).toBeVisible();
+ await expect(
+ page.getByText(
+ "Choose your provider and enter an API key to connect to the Buzz harness.",
+ ),
+ ).toBeVisible();
+ await expect(page.getByTestId("global-agent-default-harness")).toHaveCount(0);
+ await expect(
+ page.getByTestId("onboarding-use-different-harness"),
+ ).toBeVisible();
+
+ await page.getByTestId("onboarding-back").click();
+ await expect(
+ page.getByRole("heading", { name: "Connect your AI provider" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Choose a harness" }),
+ ).toHaveCount(0);
+
+ await page.getByTestId("onboarding-harness-method-api").click();
+ await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
+ await page.getByTestId("onboarding-use-different-harness").click();
+
+ await expect(
+ page.getByRole("heading", { name: "Choose a harness" }),
+ ).toBeVisible();
+ await page.waitForTimeout(250);
+ await expect(
+ page.getByRole("heading", { name: "Choose a harness" }),
+ ).toBeVisible();
+ await expect(page.getByTestId("onboarding-back")).toBeEnabled();
+ await expect(
+ renderErrors.filter((message) => message.includes("Maximum update depth")),
+ ).toHaveLength(0);
+ await page.getByTestId("onboarding-back").click();
+ await expect(
+ page.getByRole("heading", { name: "Connect with an API key" }),
+ ).toBeVisible();
+ await expect(page.getByTestId("global-agent-provider")).toBeVisible();
+ await page.getByTestId("onboarding-use-different-harness").click();
+ await expect(
+ page.getByRole("heading", { name: "Choose a harness" }),
+ ).toBeVisible();
await expect(page.getByTestId("onboarding-runtime-goose")).toBeVisible();
await expect(page.getByTestId("onboarding-runtime-buzz-agent")).toBeVisible();
+ await expect(page.getByTestId("onboarding-runtime-claude")).toHaveCount(0);
+ await expect(page.getByTestId("onboarding-runtime-codex")).toHaveCount(0);
await expect(page.getByRole("checkbox")).toHaveCount(0);
+ await expect(page.getByText(/More harnesses can be added in/)).toHaveCount(0);
+
+ const recommended = page.getByTestId(
+ "onboarding-runtime-recommended-buzz-agent",
+ );
+ const chevron = page.getByTestId("onboarding-runtime-chevron-buzz-agent");
+ await expect(recommended).toBeVisible();
+ await expect(
+ page.getByTestId("onboarding-runtime-ready-buzz-agent"),
+ ).toHaveCount(0);
+ const [recommendedBox, chevronBox] = await Promise.all([
+ recommended.boundingBox(),
+ chevron.boundingBox(),
+ ]);
+ if (!recommendedBox || !chevronBox) {
+ throw new Error("Could not measure harness status placement");
+ }
+ expect(recommendedBox.x).toBeLessThan(chevronBox.x);
+
+ const [iconBox, titleBox] = await Promise.all([
+ page.getByTestId("onboarding-runtime-icon-buzz-agent").boundingBox(),
+ page.getByTestId("onboarding-runtime-title-buzz-agent").boundingBox(),
+ ]);
+ if (!iconBox || !titleBox) {
+ throw new Error("Could not measure harness icon alignment");
+ }
+ const iconCenter = iconBox.y + iconBox.height / 2;
+ const titleCenter = titleBox.y + titleBox.height / 2;
+ expect(Math.abs(iconCenter - titleCenter)).toBeLessThanOrEqual(1);
+
+ const selectableBuzzCard = page.getByTestId("onboarding-runtime-buzz-agent");
+ await selectableBuzzCard.hover();
+ await expect(selectableBuzzCard).not.toHaveCSS(
+ "background-color",
+ "rgba(0, 0, 0, 0)",
+ );
+ const [rowBackground, recommendedBackground] = await Promise.all([
+ selectableBuzzCard.evaluate(
+ (element) => window.getComputedStyle(element).backgroundColor,
+ ),
+ recommended.evaluate(
+ (element) => window.getComputedStyle(element).backgroundColor,
+ ),
+ ]);
+ expect(recommendedBackground).not.toBe(rowBackground);
+
const setupSkip = page.getByTestId("onboarding-setup-skip");
await expect(setupSkip).toBeVisible();
+ await expect(page.getByTestId("onboarding-setup-next")).toHaveCount(0);
await expect(setupSkip).not.toHaveClass(/animate-in|fade-in/);
- const harnessNote = page.getByText(/More harnesses \(Cursor, Grok, Amp…\)/);
- await expect(harnessNote).toBeVisible();
- const [lastHarnessBox, harnessNoteBox] = await Promise.all([
- page.getByTestId("onboarding-runtime-buzz-agent").boundingBox(),
- harnessNote.boundingBox(),
- ]);
- if (!lastHarnessBox || !harnessNoteBox) {
- throw new Error("Could not measure harness note placement");
- }
- expect(harnessNoteBox.y).toBeGreaterThan(
- lastHarnessBox.y + lastHarnessBox.height,
+
+ await page.getByTestId("onboarding-runtime-details-buzz-agent").click();
+ await expect(
+ page.getByRole("heading", { name: "Connect with an API key" }),
+ ).toBeVisible();
+ await expect(page.getByTestId("global-agent-provider")).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Choose your model settings" }),
+ ).toHaveCount(0);
+ await expect(page.getByTestId("onboarding-setup-next")).toHaveCount(0);
+});
+
+test("API selection survives a pending forced discovery with multiple ready harnesses", async ({
+ page,
+}) => {
+ await installMockBridge(
+ page,
+ {
+ acpRuntimesCatalog: [
+ runtime("buzz-agent", "available", { status: "not_applicable" }),
+ runtime("goose", "available", { status: "not_applicable" }),
+ ],
+ acpRuntimesDelayMs: 3_000,
+ },
+ { skipCommunitySeed: true, skipOnboardingSeed: true },
);
+ await page.goto("/");
+ await navigateToSetupPage(page, null);
+
+ // Prime the shared catalog, then leave and re-enter setup so the second
+ // forced discovery runs against cached multi-runtime readiness.
+ await page.getByTestId("onboarding-harness-method-api").click();
+ await expect(
+ page.getByRole("heading", { name: "Choose a harness" }),
+ ).toBeVisible();
+ await page.getByTestId("onboarding-runtime-details-buzz-agent").click();
+ await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
+ await page.getByTestId("onboarding-back").click();
+ await expect(
+ page.getByRole("heading", { name: "Choose a harness" }),
+ ).toBeVisible();
+ await page.getByTestId("onboarding-back").click();
+
+ await page.getByTestId("onboarding-harness-method-api").click();
+ await expect(page.getByTestId("onboarding-page-config")).toHaveCount(0);
+ await expect(page.getByTestId("onboarding-page-config")).toBeVisible({
+ timeout: 10_000,
+ });
+ await expect(page.getByTestId("global-agent-provider")).toBeVisible();
+ await expect(page.getByTestId("global-agent-default-harness")).toHaveCount(0);
+ expect(await readSavedRuntime(page)).toBeNull();
});
test("setup distinguishes a missing CLI from an installed desktop app", async ({
@@ -138,13 +309,71 @@ test("setup distinguishes a missing CLI from an installed desktop app", async ({
await navigateToSetupPage(page);
const card = page.getByTestId("onboarding-runtime-codex");
- await expect(card).toContainText("CLI not detected.");
- await expect(card.getByTestId("onboarding-runtime-install-codex")).toHaveText(
- "INSTALL",
+ await expect(card).not.toContainText("CLI not detected");
+ await expect(
+ card.getByTestId("onboarding-runtime-install-codex"),
+ ).toHaveCount(0);
+
+ await page.getByTestId("onboarding-runtime-details-codex").click();
+ await expect(
+ page.getByTestId("onboarding-harness-setup-guide"),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Set up Codex" }),
+ ).toBeVisible();
+ await expect(
+ page.getByTestId("onboarding-harness-open-setup-guide"),
+ ).toBeVisible();
+ const setupGuideCard = page.getByTestId(
+ "onboarding-harness-setup-guide-card",
+ );
+ await expect(setupGuideCard).toContainText("Codex");
+ await expect(setupGuideCard).toContainText(
+ "Codex is not detected on this computer.",
);
+ await expect(
+ setupGuideCard.getByTestId("onboarding-harness-open-setup-guide"),
+ ).toHaveText("Open guide");
+ await expect(
+ page.getByTestId("onboarding-runtime-install-codex"),
+ ).toHaveCount(0);
+ await expect(
+ page
+ .getByTestId("onboarding-page-2")
+ .locator(".buzz-onboarding-transition-line"),
+ ).toHaveAttribute("data-onboarding-direction", "forward");
+
+ await page.getByTestId("onboarding-back").click();
+ await expect(card).toBeVisible();
+ await expect(
+ page
+ .getByTestId("onboarding-page-2")
+ .locator(".buzz-onboarding-transition-line"),
+ ).toHaveAttribute("data-onboarding-direction", "backward");
});
-test("ready state is detected and enables Next without persisting a default", async ({
+test("setup explains when an installed ACP adapter needs updating", async ({
+ page,
+}) => {
+ await installMockBridge(
+ page,
+ {
+ acpRuntimesCatalog: [
+ runtime("codex", "adapter_outdated", { status: "unknown" }),
+ ],
+ },
+ { skipCommunitySeed: true, skipOnboardingSeed: true },
+ );
+ await page.goto("/");
+ await navigateToSetupPage(page);
+
+ await page.getByTestId("onboarding-runtime-details-codex").click();
+ await expect(
+ page.getByTestId("onboarding-harness-setup-guide-card"),
+ ).toContainText("Codex needs an ACP adapter update.");
+});
+
+test("a ready harness opens its provider settings without an intermediate page", async ({
page,
}) => {
await installMockBridge(
@@ -160,8 +389,8 @@ test("ready state is detected and enables Next without persisting a default", as
await page.goto("/");
await navigateToSetupPage(page);
- await expect(page.getByTestId("onboarding-runtime-ready-claude")).toHaveText(
- "READY",
+ await expect(page.getByTestId("onboarding-runtime-ready-claude")).toHaveCount(
+ 0,
);
await expect(
page.getByTestId("onboarding-runtime-checkmark-claude"),
@@ -169,7 +398,16 @@ test("ready state is detected and enables Next without persisting a default", as
await expect(
page.getByTestId("onboarding-runtime-checkmark-codex"),
).toHaveCount(0);
- await expect(page.getByTestId("onboarding-setup-next")).toBeEnabled();
+ await expect(page.getByTestId("onboarding-setup-next")).toHaveCount(0);
+ await page.getByTestId("onboarding-runtime-details-claude").click();
+ await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Choose your model settings" }),
+ ).toBeVisible();
+ await expect(page.getByTestId("onboarding-setup-next")).toHaveCount(0);
+ await expect(page.getByTestId("global-agent-default-harness")).toHaveText(
+ "Claude Code",
+ );
expect(await readSavedRuntime(page)).toBeNull();
});
@@ -182,7 +420,7 @@ test("setup shows runtime discovery loading before rendering harnesses", async (
acpRuntimesCatalog: [
runtime("claude", "available", { status: "logged_in" }),
],
- acpRuntimesDelayMs: 500,
+ acpRuntimesDelayMs: 3_000,
},
{ skipCommunitySeed: true, skipOnboardingSeed: true },
);
@@ -190,6 +428,9 @@ test("setup shows runtime discovery loading before rendering harnesses", async (
await navigateToSetupPage(page);
await expect(page.getByTestId("onboarding-runtime-loading")).toBeVisible();
+ await expect(page.getByTestId("onboarding-runtime-loading")).toHaveText(
+ "Loading providers…",
+ );
await expect(page.getByTestId("onboarding-runtime-claude")).toBeVisible();
await expect(page.getByTestId("onboarding-runtime-loading")).toHaveCount(0);
});
@@ -208,10 +449,11 @@ test("unknown authentication can be checked again", async ({ page }) => {
const checkAgain = page.getByRole("button", {
name: "Check Claude Code again",
});
- await expect(checkAgain).toHaveText("CHECK AGAIN");
+ await expect(checkAgain).toHaveText("Check again");
await checkAgain.click();
- await expect(page.getByTestId("onboarding-runtime-ready-claude")).toHaveText(
- "READY",
+ await expect(page.getByTestId("onboarding-runtime-claude")).toHaveAttribute(
+ "data-ready",
+ "true",
);
});
@@ -231,14 +473,21 @@ test("auth discovery failure stays actionable without exposing internals", async
await page.goto("/");
await navigateToSetupPage(page);
- const card = page.getByTestId("onboarding-runtime-claude");
+ const signInRequired = page.getByTestId(
+ "onboarding-runtime-sign-in-required-claude",
+ );
+ await expect(signInRequired).toHaveText("Sign in required");
+ await expect(signInRequired).not.toHaveClass(/font-mono/);
+ await page.getByTestId("onboarding-runtime-details-claude").click();
await expect(
- card.getByRole("status", { name: /Sign-in unavailable/ }),
+ page.getByRole("status", { name: /Sign-in unavailable/ }),
).toBeVisible();
await expect(
- card.getByTestId("onboarding-runtime-instructions-claude"),
- ).toHaveText("SIGN IN");
- await expect(card).not.toContainText("sensitive auth discovery details");
+ page.getByTestId("onboarding-runtime-instructions-claude"),
+ ).toHaveText("Sign in");
+ await expect(page.locator("body")).not.toContainText(
+ "sensitive auth discovery details",
+ );
});
test("terminal launch failure keeps Sign in available", async ({ page }) => {
@@ -267,14 +516,19 @@ test("terminal launch failure keeps Sign in available", async ({ page }) => {
await page.goto("/");
await navigateToSetupPage(page);
- const card = page.getByTestId("onboarding-runtime-claude");
- const signIn = card.getByRole("button", { name: "Sign in to Claude Code" });
+ await expect(
+ page.getByTestId("onboarding-runtime-sign-in-required-claude"),
+ ).toHaveText("Sign in required");
+ await page.getByTestId("onboarding-runtime-details-claude").click();
+ const signIn = page.getByRole("button", { name: "Sign in to Claude Code" });
await signIn.click();
await expect(
- card.getByRole("status", { name: /Sign-in failed/ }),
+ page.getByRole("status", { name: /Sign-in failed/ }),
).toBeVisible();
- await expect(signIn).toHaveText("SIGN IN");
- await expect(card).not.toContainText("sensitive launch details");
+ await expect(signIn).toHaveText("Sign in");
+ await expect(page.locator("body")).not.toContainText(
+ "sensitive launch details",
+ );
});
test("sign in stays pending until catalog detection confirms Ready", async ({
@@ -304,131 +558,27 @@ test("sign in stays pending until catalog detection confirms Ready", async ({
await page.goto("/");
await navigateToSetupPage(page);
+ await page.getByTestId("onboarding-runtime-details-claude").click();
+ await expect(
+ page.getByText("Claude subscription", { exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByText("Buzz will open a sign-in window for Claude Code."),
+ ).toBeVisible();
const signIn = page.getByRole("button", { name: "Sign in to Claude Code" });
- await expect(signIn).toHaveText("SIGN IN");
- await expect(page.getByTestId("onboarding-setup-next")).toBeDisabled();
+ await expect(signIn).toHaveText("Sign in");
+ await expect(page.getByTestId("onboarding-setup-next")).toHaveCount(0);
await signIn.click();
- await expect(signIn).toHaveText("CHECKING…");
- await expect(page.getByTestId("onboarding-setup-next")).toBeDisabled();
- await expect(page.getByTestId("onboarding-runtime-ready-claude")).toHaveText(
- "READY",
- { timeout: 5_000 },
- );
- await expect(page.getByTestId("onboarding-setup-next")).toBeEnabled();
-});
-
-test("failed install can be retried without shifting card content", async ({
- page,
-}) => {
- const notInstalled = runtime("claude", "adapter_missing", {
- status: "unknown",
+ await expect(signIn).toHaveText("Checking…");
+ await expect(page.getByTestId("onboarding-page-config")).toBeVisible({
+ timeout: 5_000,
});
- await installMockBridge(
- page,
- {
- acpRuntimesCatalog: [notInstalled],
- installAcpRuntimeResults: [
- {
- success: false,
- steps: [
- {
- step: "adapter",
- command: "mock install claude",
- success: false,
- stdout: "",
- stderr: "sensitive install details",
- exit_code: 1,
- },
- ],
- },
- {
- success: true,
- steps: [
- {
- step: "adapter",
- command: "mock install claude",
- success: true,
- stdout: "installed",
- stderr: "",
- exit_code: 0,
- },
- ],
- },
- ],
- acpRuntimesCatalogAfterInstall: [
- runtime("claude", "available", { status: "logged_in" }),
- ],
- },
- { skipCommunitySeed: true, skipOnboardingSeed: true },
- );
- await page.goto("/");
- await navigateToSetupPage(page);
-
- const card = page.getByTestId("onboarding-runtime-claude");
- const heading = card.getByRole("heading", { name: "Claude Code" });
- const headingTop = await heading.evaluate(
- (element) => element.getBoundingClientRect().top,
- );
- const install = page.getByTestId("onboarding-runtime-install-claude");
- await install.click();
- const error = page.getByTestId("onboarding-runtime-error-claude");
- await expect(error).toBeVisible();
- await expect(install).toHaveText("RETRY INSTALL");
- await expect(error).not.toContainText("sensitive install details");
- expect(
- await heading.evaluate((element) => element.getBoundingClientRect().top),
- ).toBe(headingTop);
- await install.click();
- await expect(page.getByTestId("onboarding-runtime-ready-claude")).toHaveText(
- "READY",
- );
-});
-
-test("install transitions through Sign in to Ready", async ({ page }) => {
- const notInstalled = runtime("claude", "adapter_missing", {
- status: "unknown",
- });
- const loggedOut = runtime("claude", "available", { status: "logged_out" });
- const loggedIn = runtime("claude", "available", { status: "logged_in" });
- await installMockBridge(
- page,
- {
- acpRuntimesCatalog: [notInstalled],
- acpRuntimesCatalogAfterInstallSequence: [[loggedOut], [loggedIn]],
- installAcpRuntimeDelayMs: 500,
- acpAuthMethods: {
- claude: {
- methods: [
- {
- id: "subscription",
- name: "Claude.ai subscription",
- description: null,
- type: "terminal",
- },
- ],
- },
- },
- },
- { skipCommunitySeed: true, skipOnboardingSeed: true },
- );
- await page.goto("/");
- await navigateToSetupPage(page);
-
- const install = page.getByTestId("onboarding-runtime-install-claude");
- await expect(install).toHaveText("INSTALL");
- await install.click();
-
- const signIn = page.getByRole("button", { name: "Sign in to Claude Code" });
- await expect(signIn).toHaveText("SIGN IN");
- await expect(page.getByTestId("onboarding-setup-next")).toBeDisabled();
- await signIn.click();
- await expect(page.getByTestId("onboarding-runtime-ready-claude")).toHaveText(
- "READY",
- { timeout: 5_000 },
- );
await expect(
- page.getByTestId("onboarding-runtime-checkmark-claude"),
- ).toHaveCount(0);
+ page.getByRole("heading", { name: "Choose your model settings" }),
+ ).toBeVisible();
+ await expect(page.getByTestId("global-agent-default-harness")).toHaveText(
+ "Claude Code",
+ );
});
test("defaults waits for baked configuration before rendering fields", async ({
@@ -449,7 +599,7 @@ test("defaults waits for baked configuration before rendering fields", async ({
);
await page.goto("/");
await navigateToSetupPage(page);
- await page.getByTestId("onboarding-setup-next").click();
+ await chooseHarnessAndContinue(page);
await expect(page.getByText("Loading…")).toBeVisible();
await expect(page.getByTestId("global-agent-default-harness")).toHaveText(
@@ -477,7 +627,7 @@ test("defaults renders only fields supported by the selected harness", async ({
);
await page.goto("/");
await navigateToSetupPage(page);
- await page.getByTestId("onboarding-setup-next").click();
+ await chooseHarnessAndContinue(page);
await expect(page.getByTestId("global-agent-default-harness")).toHaveText(
"Claude Code",
@@ -515,7 +665,7 @@ test("defaults hides model when optional harness has empty discovery", async ({
);
await page.goto("/");
await navigateToSetupPage(page);
- await page.getByTestId("onboarding-setup-next").click();
+ await chooseHarnessAndContinue(page);
await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
await expect(page.getByTestId("global-agent-default-harness")).toHaveText(
@@ -548,7 +698,7 @@ test("defaults keeps model control when optional harness discovery fails", async
);
await page.goto("/");
await navigateToSetupPage(page);
- await page.getByTestId("onboarding-setup-next").click();
+ await chooseHarnessAndContinue(page);
await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
await expect(page.getByTestId("global-agent-default-harness")).toHaveText(
@@ -582,7 +732,7 @@ test("defaults can be skipped while loading without persisting configuration", a
);
await page.goto("/");
await navigateToSetupPage(page);
- await page.getByTestId("onboarding-setup-next").click();
+ await chooseHarnessAndContinue(page);
await expect(page.getByText("Loading…")).toBeVisible();
await page.getByTestId("onboarding-config-skip").click();
@@ -611,7 +761,7 @@ test("defaults stages auto-selection and edits without writing when skipped", as
);
await page.goto("/");
await navigateToSetupPage(page);
- await page.getByTestId("onboarding-setup-next").click();
+ await chooseHarnessAndContinue(page);
await expect(page.getByTestId("global-agent-default-harness")).toHaveText(
"Claude Code",
@@ -658,19 +808,15 @@ test("Back preserves incomplete defaults draft without writing", async ({
{ skipCommunitySeed: true, skipOnboardingSeed: true },
);
await page.goto("/");
- await navigateToSetupPage(page);
- await page.getByTestId("onboarding-setup-next").click();
+ await navigateToSetupPage(page, "api");
+ await chooseHarnessAndContinue(page);
await expect(
page
.getByTestId("onboarding-page-config")
.locator(".buzz-onboarding-transition-line"),
).toHaveAttribute("data-onboarding-direction", "forward");
- const harness = page.getByTestId("global-agent-default-harness");
- await harness.click();
- await page
- .getByTestId("global-agent-default-harness-option-buzz-agent")
- .click();
+ await expect(page.getByTestId("global-agent-default-harness")).toHaveCount(0);
await page.getByTestId("global-agent-provider").click();
await page.getByTestId("global-agent-provider-option-anthropic").click();
await expect(page.getByTestId("onboarding-finish")).toBeDisabled();
@@ -685,13 +831,14 @@ test("Back preserves incomplete defaults draft without writing", async ({
expect(await readSavedRuntime(page)).toBeNull();
expect(await readGlobalConfigSetterCallCount(page)).toBe(0);
- await page.getByTestId("onboarding-setup-next").click();
+ await page.getByTestId("onboarding-harness-method-api").click();
+ await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
await expect(
page
.getByTestId("onboarding-page-config")
.locator(".buzz-onboarding-transition-line"),
).toHaveAttribute("data-onboarding-direction", "forward");
- await expect(harness).toHaveText("Buzz");
+ await expect(page.getByTestId("global-agent-default-harness")).toHaveCount(0);
await expect(page.getByTestId("global-agent-provider")).toHaveText(
"Anthropic",
);
@@ -722,7 +869,7 @@ test("defaults auto-selects the only ready visible harness", async ({
);
await page.goto("/");
await navigateToSetupPage(page);
- await page.getByTestId("onboarding-setup-next").click();
+ await chooseHarnessAndContinue(page);
await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
await expect(page.getByTestId("global-agent-default-harness")).toHaveText(
@@ -732,7 +879,9 @@ test("defaults auto-selects the only ready visible harness", async ({
expect(await readSavedRuntime(page)).toBeNull();
});
-test("Next persists the latest staged harness choice", async ({ page }) => {
+test("Next persists the harness chosen from the subscription list", async ({
+ page,
+}) => {
await installMockBridge(
page,
{
@@ -752,19 +901,17 @@ test("Next persists the latest staged harness choice", async ({ page }) => {
);
await page.goto("/");
await navigateToSetupPage(page);
- await page.getByTestId("onboarding-setup-next").click();
+ await chooseHarnessAndContinue(page);
- const harness = page.getByTestId("global-agent-default-harness");
- await harness.click();
- await page.getByTestId("global-agent-default-harness-option-claude").click();
- await harness.click();
- await page.getByTestId("global-agent-default-harness-option-codex").click();
+ await expect(page.getByTestId("global-agent-default-harness")).toHaveText(
+ "Claude Code",
+ );
const finish = page.getByTestId("onboarding-finish");
await expect(finish).toBeEnabled();
expect(await readGlobalConfigSetterCallCount(page)).toBe(0);
await finish.click();
await expect(page.getByText("Join or create a community")).toBeVisible();
- await expect.poll(() => readSavedRuntime(page)).toBe("codex");
+ await expect.poll(() => readSavedRuntime(page)).toBe("claude");
});
test("Next shows saving state and advances only after persistence", async ({
@@ -789,11 +936,11 @@ test("Next shows saving state and advances only after persistence", async ({
);
await page.goto("/");
await navigateToSetupPage(page);
- await page.getByTestId("onboarding-setup-next").click();
+ await chooseHarnessAndContinue(page);
- const harness = page.getByTestId("global-agent-default-harness");
- await harness.click();
- await page.getByTestId("global-agent-default-harness-option-codex").click();
+ await expect(page.getByTestId("global-agent-default-harness")).toHaveText(
+ "Claude Code",
+ );
await page.getByTestId("onboarding-finish").click();
await expect(page.getByTestId("onboarding-finish")).toHaveText("Saving…");
@@ -803,7 +950,7 @@ test("Next shows saving state and advances only after persistence", async ({
expect(await readSavedRuntime(page)).toBeNull();
await expect(page.getByText("Join or create a community")).toBeVisible();
- expect(await readSavedRuntime(page)).toBe("codex");
+ expect(await readSavedRuntime(page)).toBe("claude");
});
test("Next keeps the draft and retries after a save failure", async ({
@@ -827,7 +974,7 @@ test("Next keeps the draft and retries after a save failure", async ({
);
await page.goto("/");
await navigateToSetupPage(page);
- await page.getByTestId("onboarding-setup-next").click();
+ await chooseHarnessAndContinue(page);
await expect(page.getByTestId("global-agent-default-harness")).toHaveText(
"Claude Code",
);
@@ -848,7 +995,7 @@ test("Next keeps the draft and retries after a save failure", async ({
expect(await readGlobalConfigSetterCallCount(page)).toBe(2);
});
-test("defaults requires a choice when multiple visible harnesses are ready", async ({
+test("defaults carries the chosen subscription harness forward", async ({
page,
}) => {
await installMockBridge(
@@ -871,28 +1018,26 @@ test("defaults requires a choice when multiple visible harnesses are ready", asy
);
await page.goto("/");
await navigateToSetupPage(page);
- await page.getByTestId("onboarding-setup-next").click();
+ await chooseHarnessAndContinue(page);
await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
const harness = page.getByTestId("global-agent-default-harness");
- await expect(harness).toHaveText("Select a harness");
- await expect(page.getByTestId("onboarding-finish")).toBeDisabled();
+ await expect(harness).toHaveText("Claude Code");
+ await expect(page.getByTestId("onboarding-finish")).toBeEnabled();
await harness.click();
await expect(
page.getByTestId("global-agent-default-harness-option-claude"),
).toBeVisible();
await expect(
page.getByTestId("global-agent-default-harness-option-codex"),
- ).toBeVisible();
+ ).toHaveCount(0);
await expect(
page.getByTestId("global-agent-default-harness-option-goose"),
- ).toBeVisible();
+ ).toHaveCount(0);
await expect(
page.getByTestId("global-agent-default-harness-option-buzz-agent"),
- ).toBeVisible();
- await page.getByTestId("global-agent-default-harness-option-codex").click();
- await expect(harness).toHaveText("Codex");
- await expect(page.getByTestId("onboarding-finish")).toBeEnabled();
+ ).toHaveCount(0);
+ await page.keyboard.press("Escape");
expect(await readSavedRuntime(page)).toBeNull();
});
@@ -905,173 +1050,71 @@ test("defaults requires a choice when multiple visible harnesses are ready", asy
* This is the behavioral regression test for the per-card mutation fix
* (Bug B) and the multiline tooltip fix (Bug A / F3 from Thufir pass 1).
*/
-test("concurrent installs each keep their own state — one fails, one succeeds", async ({
+test("Finish stays disabled until a provider-required harness is fully configured", async ({
page,
}) => {
- // Realistic 512-head + 1024-tail shape: many short lines followed by one
- // long unbroken Windows path. This exercises both overflow axes:
- // • vertical: enough lines to exceed max-h-48 (192px at ~16px/line)
- // • horizontal: the long path has no spaces, so only break-words prevents
- // scrollWidth > clientWidth.
- const longWindowsPath =
- "C:\\Users\\willp\\AppData\\Roaming\\npm\\node_modules\\@agentclientprotocol\\claude-agent-acp\\dist\\bin\\claude-agent-acp.exe";
- const multilineError = [
- "npm ERR! code EACCES",
- "npm ERR! syscall mkdir",
- "npm ERR! path C:\\Users\\willp\\AppData\\Roaming\\npm",
- "npm ERR! errno -4048",
- "npm ERR! Error: EACCES: permission denied, mkdir 'C:\\Users\\willp\\AppData\\Roaming\\npm'",
- "npm ERR! { [Error: EACCES: permission denied, mkdir 'C:\\Users\\willp\\AppData\\Roaming\\npm']",
- "npm ERR! errno: -4048,",
- "npm ERR! code: 'EACCES',",
- "npm ERR! syscall: 'mkdir',",
- "npm ERR! path: 'C:\\\\Users\\\\willp\\\\AppData\\\\Roaming\\\\npm' }",
- "npm ERR!",
- "npm ERR! The operation was rejected by your operating system.",
- "npm ERR! It is likely you do not have the permissions to access this file as the current user",
- "npm ERR!",
- `npm ERR! If you believe this might be a permissions issue, please double-check the`,
- `npm ERR! permissions of the file and its containing directories, or try running`,
- `npm ERR! the command again as root/Administrator.`,
- "",
- `Hint: Run as Administrator or change npm prefix: npm config set prefix ${longWindowsPath}`,
- ].join("\n");
- const claudeNotInstalled = runtime("claude", "adapter_missing", {
- status: "unknown",
- });
- const codexNotInstalled = runtime("codex", "adapter_missing", {
- status: "unknown",
- });
await installMockBridge(
page,
{
- acpRuntimesCatalog: [claudeNotInstalled, codexNotInstalled],
- // Claude: long delay then failure with multiline stderr + hint.
- // Codex: short delay then success.
- // Per-runtime config lets both be in flight simultaneously.
- installAcpRuntimeByRuntime: {
- claude: {
- delayMs: 600,
- result: {
- success: false,
- steps: [
- {
- step: "adapter",
- command: "npm install -g @agentclientprotocol/claude-agent-acp",
- success: false,
- stdout: "",
- stderr: multilineError,
- exit_code: 1,
- },
- ],
- },
- },
- codex: {
- delayMs: 200,
- result: {
- success: true,
- steps: [
- {
- step: "adapter",
- command: "npm install -g @zed-industries/codex-acp",
- success: true,
- stdout: "added 1 package",
- stderr: "",
- exit_code: 0,
- },
- ],
- },
- },
- },
- acpRuntimesCatalogAfterInstall: [
- runtime("claude", "adapter_missing", { status: "unknown" }),
- runtime("codex", "available", { status: "logged_in" }),
+ acpRuntimesCatalog: [
+ runtime("buzz-agent", "available", { status: "not_applicable" }),
],
+ discoverAgentModels: {
+ models: [{ id: "claude-sonnet-4", name: "Claude Sonnet 4" }],
+ supportsSwitching: true,
+ },
+ globalAgentConfig: {
+ env_vars: {},
+ provider: null,
+ model: null,
+ preferred_runtime: null,
+ },
},
{ skipCommunitySeed: true, skipOnboardingSeed: true },
);
await page.goto("/");
- await navigateToSetupPage(page);
-
- const claudeInstall = page.getByTestId("onboarding-runtime-install-claude");
- const codexInstall = page.getByTestId("onboarding-runtime-install-codex");
-
- // Start both installs before either settles.
- await claudeInstall.click();
- await codexInstall.click();
-
- // While in flight: both install buttons must be absent (no duplicate clicks).
- await expect(claudeInstall).toHaveCount(0);
- await expect(codexInstall).toHaveCount(0);
+ await navigateToSetupPage(page, "api");
+ await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
- // Codex settles first (shorter delay): success indicator, no error.
- await expect(page.getByTestId("onboarding-runtime-ready-codex")).toBeVisible({
- timeout: 3_000,
- });
- await expect(page.getByTestId("onboarding-runtime-error-codex")).toHaveCount(
- 0,
- );
+ // buzz-agent auto-selects as the only ready harness, but with no provider
+ // configured the default is not launchable — Finish must be gated.
+ await expect(page.getByTestId("global-agent-default-harness")).toHaveCount(0);
+ const finish = page.getByTestId("onboarding-finish");
+ await expect(finish).toBeDisabled();
- // Claude still in flight: its install button must still be absent.
- await expect(claudeInstall).toHaveCount(0);
+ // Configure provider + credential; model resolves via discovery/fallback.
+ await page.getByTestId("global-agent-provider").click();
+ await page.getByTestId("global-agent-provider-option-anthropic").click();
+ await expect(
+ page.getByText("ANTHROPIC_API_KEY", { exact: true }),
+ ).toHaveCount(0);
+ await expect(page.getByTestId("global-agent-model")).toHaveCount(0);
+ await expect(
+ page.getByTestId("global-agent-thinking-effort-select"),
+ ).toHaveCount(0);
- // Claude settles: failure error visible; codex still shows ready (not reset).
- const claudeError = page.getByTestId("onboarding-runtime-error-claude");
- await expect(claudeError).toBeVisible({ timeout: 3_000 });
+ await page.getByTestId("persona-provider-api-key").fill("sk-test-key");
+ await expect(page.getByTestId("global-agent-model")).toBeVisible();
await expect(
- page.getByTestId("onboarding-runtime-ready-codex"),
+ page.getByTestId("global-agent-thinking-effort-select"),
).toBeVisible();
- await expect(page.getByTestId("onboarding-runtime-error-codex")).toHaveCount(
- 0,
- );
- // The error trigger has the full aria-label (label + detail).
- await expect(claudeError).toHaveAttribute("aria-label", /npm ERR!/);
- // Open the tooltip and verify the detail span handles overflow correctly:
- // • vertical overflow exists and is scrollable (max-h-48 + overflow-y-auto)
- // • no horizontal overflow (break-words forces the long unbroken path to wrap)
- await claudeError.focus();
- const tooltip = page.getByRole("tooltip");
- await expect(tooltip).toBeVisible({ timeout: 2_000 });
- await expect(tooltip).toContainText("npm ERR! code EACCES");
- await expect(tooltip).toContainText("Hint: Run as Administrator");
-
- // Moving from the trigger into the portalled tooltip must keep it open so
- // pointer users can operate the scrollable error detail.
- const tooltipBox = await tooltip.boundingBox();
- if (!tooltipBox) throw new Error("Expected runtime error tooltip bounds");
- await page.mouse.move(tooltipBox.x + 8, tooltipBox.y + 8);
- await expect(tooltip).toBeVisible();
- await expect(tooltip).toHaveCSS("pointer-events", "auto");
-
- // Locate the scroll container using page-level locator since Radix portals
- // can place content outside the tooltip role element's subtree in the DOM.
- // Use .first() because Radix keeps a hidden duplicate in the light DOM.
- const detailSpan = page.locator("span.overflow-y-auto").first();
- await expect(detailSpan).toBeVisible();
-
- // Vertical: scrollHeight must exceed clientHeight (content taller than max-h-48).
- // Scroll position must advance when set, proving scrollability.
- const isVerticallyScrollable = await detailSpan.evaluate((el) => {
- return el.scrollHeight > el.clientHeight;
- });
- expect(isVerticallyScrollable).toBe(true);
-
- // Confirm scroll position can actually advance.
- await detailSpan.evaluate((el) => {
- el.scrollTop = 9999;
- });
- const scrolledDown = await detailSpan.evaluate((el) => el.scrollTop > 0);
- expect(scrolledDown).toBe(true);
+ const modelBox = await page.getByTestId("global-agent-model").boundingBox();
+ const effortBox = await page
+ .getByTestId("global-agent-thinking-effort-select")
+ .boundingBox();
+ expect(modelBox).not.toBeNull();
+ expect(effortBox).not.toBeNull();
+ expect(Math.abs((modelBox?.y ?? 0) - (effortBox?.y ?? 0))).toBeLessThan(2);
+ expect(modelBox?.x ?? 0).toBeLessThan(effortBox?.x ?? 0);
- // Horizontal: break-words must prevent horizontal overflow.
- const hasHorizontalOverflow = await detailSpan.evaluate((el) => {
- return el.scrollWidth > el.clientWidth;
- });
- expect(hasHorizontalOverflow).toBe(false);
+ await expect(finish).toBeEnabled();
+ await finish.click();
+ await expect(page.getByText("Join or create a community")).toBeVisible();
+ expect(await readSavedRuntime(page)).toBe("buzz-agent");
});
-test("Finish stays disabled until a provider-required harness is fully configured", async ({
+test("API key options stay hidden when credential validation is not accepted", async ({
page,
}) => {
await installMockBridge(
@@ -1080,10 +1123,8 @@ test("Finish stays disabled until a provider-required harness is fully configure
acpRuntimesCatalog: [
runtime("buzz-agent", "available", { status: "not_applicable" }),
],
- discoverAgentModels: {
- models: [{ id: "claude-sonnet-4", name: "Claude Sonnet 4" }],
- supportsSwitching: true,
- },
+ discoverAgentModelsError:
+ "Anthropic model discovery HTTP 401: invalid x-api-key",
globalAgentConfig: {
env_vars: {},
provider: null,
@@ -1094,27 +1135,21 @@ test("Finish stays disabled until a provider-required harness is fully configure
{ skipCommunitySeed: true, skipOnboardingSeed: true },
);
await page.goto("/");
- await navigateToSetupPage(page);
- await page.getByTestId("onboarding-setup-next").click();
- await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
-
- // buzz-agent auto-selects as the only ready harness, but with no provider
- // configured the default is not launchable — Finish must be gated.
- await expect(page.getByTestId("global-agent-default-harness")).toHaveText(
- "Buzz",
- );
- const finish = page.getByTestId("onboarding-finish");
- await expect(finish).toBeDisabled();
-
- // Configure provider + credential; model resolves via discovery/fallback.
+ await navigateToSetupPage(page, "api");
await page.getByTestId("global-agent-provider").click();
await page.getByTestId("global-agent-provider-option-anthropic").click();
- await page.getByTestId("persona-provider-api-key").fill("sk-test-key");
+ await page.getByTestId("persona-provider-api-key").fill("invalid-key");
- await expect(finish).toBeEnabled();
- await finish.click();
- await expect(page.getByText("Join or create a community")).toBeVisible();
- expect(await readSavedRuntime(page)).toBe("buzz-agent");
+ await expect(
+ page.getByText(
+ "We couldn’t validate this API key. Check the key or your connection and try again.",
+ ),
+ ).toBeVisible();
+ await expect(page.getByTestId("global-agent-model")).toHaveCount(0);
+ await expect(
+ page.getByTestId("global-agent-thinking-effort-select"),
+ ).toHaveCount(0);
+ await expect(page.getByTestId("onboarding-finish")).toBeDisabled();
});
test("baked build config keeps Finish enabled without manual provider setup", async ({
@@ -1145,14 +1180,11 @@ test("baked build config keeps Finish enabled without manual provider setup", as
{ skipCommunitySeed: true, skipOnboardingSeed: true },
);
await page.goto("/");
- await navigateToSetupPage(page);
- await page.getByTestId("onboarding-setup-next").click();
+ await navigateToSetupPage(page, "api");
await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
// Internal builds bake provider/model/credentials — the gate must treat
// baked config as complete and never block Finish.
- await expect(page.getByTestId("global-agent-default-harness")).toHaveText(
- "Buzz",
- );
+ await expect(page.getByTestId("global-agent-default-harness")).toHaveCount(0);
await expect(page.getByTestId("onboarding-finish")).toBeEnabled();
});
diff --git a/desktop/tests/e2e/onboarding-avatar-skip.spec.ts b/desktop/tests/e2e/onboarding-avatar-skip.spec.ts
index 38870199cf3..d990a3f741a 100644
--- a/desktop/tests/e2e/onboarding-avatar-skip.spec.ts
+++ b/desktop/tests/e2e/onboarding-avatar-skip.spec.ts
@@ -1,8 +1,12 @@
-import { expect, test } from "@playwright/test";
+import { expect, type Page, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
-import { expectEmojiMartStylesInstalled } from "../helpers/css";
+import {
+ expectEmojiMartStylesInstalled,
+ expectSmoothCorners,
+} from "../helpers/css";
+import { installFakeCamera } from "../helpers/fakeCamera";
import { seedActiveIdentity } from "../helpers/onboarding";
const BLANK_TYLER_IDENTITY = {
@@ -12,6 +16,25 @@ const BLANK_TYLER_IDENTITY = {
const SHOTS = "test-results/screenshots-onboarding";
+async function selectFirstEmojiFromPicker(page: Page) {
+ const picker = page.locator("em-emoji-picker");
+ await expect(picker).toBeVisible();
+ await expect
+ .poll(() =>
+ picker.evaluate((element) =>
+ Boolean(element.shadowRoot?.querySelector(".scroll button")),
+ ),
+ )
+ .toBe(true);
+ await picker.evaluate((element) => {
+ const button = element.shadowRoot?.querySelector(".scroll button");
+ if (!(button instanceof HTMLElement)) {
+ throw new Error("Emoji picker did not render an emoji button.");
+ }
+ button.click();
+ });
+}
+
test("avatar step always shows Skip for now button without an error", async ({
page,
}) => {
@@ -29,6 +52,16 @@ test("avatar step always shows Skip for now button without an error", async ({
await expect(skipBtn).toBeVisible();
await expect(skipBtn).toBeEnabled();
await expect(skipBtn).toHaveText("Skip for now");
+ const nextBtn = page.getByTestId("onboarding-next");
+ const [skipRadius, nextRadius] = await Promise.all([
+ skipBtn.evaluate(
+ (element) => window.getComputedStyle(element).borderRadius,
+ ),
+ nextBtn.evaluate(
+ (element) => window.getComputedStyle(element).borderRadius,
+ ),
+ ]);
+ expect(skipRadius).toBe(nextRadius);
// Capture the whole viewport: the Skip/Next/Back CTAs are portaled into the
// docked footer (a sibling of the step subtree), so a section-scoped shot
@@ -39,7 +72,7 @@ test("avatar step always shows Skip for now button without an error", async ({
});
});
-test("avatar step shares the profile emoji picker controls", async ({
+test("avatar step uses the compact prototype emoji picker", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
@@ -48,31 +81,267 @@ test("avatar step shares the profile emoji picker controls", async ({
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
- await page.getByRole("tab", { name: "Emoji" }).click();
+ await page.getByTestId("onboarding-avatar-mode-emoji").click();
const picker = page.locator("em-emoji-picker");
await expect(picker.locator("input[type='search']")).toBeVisible();
await expectEmojiMartStylesInstalled(picker);
await expect(page.getByTestId("onboarding-avatar-emoji-picker")).toHaveCSS(
"height",
- "384px",
+ "420px",
+ );
+ const segmentControl = page.getByTestId("onboarding-avatar-mode-control");
+ await expect(segmentControl).toHaveAttribute(
+ "data-slot",
+ "segmented-control",
);
+ const surfaceColors = await Promise.all([
+ segmentControl.evaluate(
+ (element) => window.getComputedStyle(element).backgroundColor,
+ ),
+ page
+ .getByTestId("onboarding-avatar-emoji-picker")
+ .evaluate((element) => window.getComputedStyle(element).backgroundColor),
+ ]);
+ expect(surfaceColors[0]).toBe(surfaceColors[1]);
+ const activeSegmentColor = await page
+ .getByTestId("onboarding-avatar-mode-indicator")
+ .evaluate((element) => window.getComputedStyle(element).backgroundColor);
+ const cardColor = await page
+ .getByTestId("onboarding-content-card")
+ .evaluate((element) => window.getComputedStyle(element).backgroundColor);
+ expect(activeSegmentColor).toBe(cardColor);
+ await expectSmoothCorners(page.getByTestId("onboarding-avatar-emoji-picker"));
+ await waitForAnimations(page);
+ await page.screenshot({
+ path: `${SHOTS}/04-avatar-compact-emoji.png`,
+ });
- const controlHeights = await picker.evaluate((element) => {
+ const pickerGeometry = await picker.evaluate((element) => {
const input = element.shadowRoot?.querySelector(
'input[type="search"]',
);
const toneControl =
element.shadowRoot?.querySelector(".search + .flex");
- if (!input || !toneControl) {
+ const firstEmojiButton =
+ element.shadowRoot?.querySelector("[aria-posinset]");
+ const firstEmojiRow =
+ element.shadowRoot?.querySelector(".row");
+ const categoryLabel =
+ element.shadowRoot?.querySelector(".category .sticky");
+ const searchHeader = element.shadowRoot?.querySelector(
+ "#root > .padding-lr",
+ );
+ const scroll = element.shadowRoot?.querySelector(".scroll");
+ const toneButton =
+ element.shadowRoot?.querySelector(".skin-tone-button");
+ const nav = element.shadowRoot?.querySelector("#nav");
+ if (
+ !input ||
+ !toneControl ||
+ !firstEmojiButton ||
+ !firstEmojiRow ||
+ !categoryLabel ||
+ !searchHeader ||
+ !scroll ||
+ !toneButton
+ ) {
throw new Error("Onboarding emoji picker controls did not render.");
}
return {
+ categoryLabelBackground:
+ window.getComputedStyle(categoryLabel).backgroundColor,
+ categoryLabelDisplay: window.getComputedStyle(categoryLabel).display,
+ categoryLabelPosition: window.getComputedStyle(categoryLabel).position,
+ categoryLabelZIndex: window.getComputedStyle(categoryLabel).zIndex,
+ inputRadius: window.getComputedStyle(input).borderRadius,
input: input.getBoundingClientRect().height,
+ firstEmojiButton: firstEmojiButton.getBoundingClientRect().height,
+ firstEmojiRowItems: firstEmojiRow.children.length,
+ navDisplay: nav ? window.getComputedStyle(nav).display : "absent",
+ searchHeaderBottomPadding:
+ window.getComputedStyle(searchHeader).paddingBottom,
+ searchHeaderSidePadding:
+ window.getComputedStyle(searchHeader).paddingLeft,
+ scrollTopPadding: window.getComputedStyle(scroll).paddingTop,
tone: toneControl.getBoundingClientRect().height,
+ toneButtonRadius: window.getComputedStyle(toneButton).borderRadius,
+ toneControlRadius: window.getComputedStyle(toneControl).borderRadius,
};
});
- expect(controlHeights).toEqual({ input: 48, tone: 48 });
+ expect(pickerGeometry).toEqual({
+ categoryLabelBackground: "rgb(245, 245, 245)",
+ categoryLabelDisplay: "block",
+ categoryLabelPosition: "sticky",
+ categoryLabelZIndex: "5",
+ firstEmojiButton: 72,
+ firstEmojiRowItems: 6,
+ input: 40,
+ inputRadius: "8px",
+ navDisplay: "absent",
+ searchHeaderBottomPadding: "8px",
+ searchHeaderSidePadding: "8px",
+ scrollTopPadding: "0px",
+ tone: 40,
+ toneButtonRadius: "4px",
+ toneControlRadius: "8px",
+ });
+
+ await picker.evaluate((element) => {
+ const toneButton =
+ element.shadowRoot?.querySelector(".skin-tone-button");
+ if (!toneButton) throw new Error("Skin tone button did not render.");
+ toneButton.click();
+ });
+ await expect
+ .poll(() =>
+ picker.evaluate((element) => {
+ const menu = element.shadowRoot?.querySelector(".menu");
+ if (!menu) return null;
+ const style = window.getComputedStyle(menu);
+ const rect = menu.getBoundingClientRect();
+ const pickerRect = element.getBoundingClientRect();
+ return {
+ bottomInsidePicker: rect.bottom <= pickerRect.bottom,
+ opacity: style.opacity,
+ topInsidePicker: rect.top >= pickerRect.top,
+ zIndex: style.zIndex,
+ };
+ }),
+ )
+ .toEqual({
+ bottomInsidePicker: true,
+ opacity: "1",
+ topInsidePicker: true,
+ zIndex: "7",
+ });
+ await waitForAnimations(page);
+ await page.screenshot({
+ path: `${SHOTS}/05-avatar-skin-tone-menu.png`,
+ });
+
+ await picker.evaluate((element) => {
+ const selectedTone =
+ element.shadowRoot?.querySelector(".menu .option");
+ const scroll = element.shadowRoot?.querySelector(".scroll");
+ if (!selectedTone || !scroll) {
+ throw new Error("Emoji picker scroll state did not render.");
+ }
+ selectedTone.click();
+ scroll.scrollTop = 48;
+ scroll.dispatchEvent(new Event("scroll"));
+ });
+ await waitForAnimations(page);
+ await page.screenshot({
+ path: `${SHOTS}/06-avatar-sticky-category.png`,
+ });
+});
+
+test("avatar step keeps a stable card and compact horizontal navigation", async ({
+ page,
+}) => {
+ await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
+ await installFakeCamera(page);
+ await installMockBridge(page, undefined, { skipOnboardingSeed: true });
+ await page.goto("/");
+
+ const card = page.getByTestId("onboarding-content-card");
+ await expect(card).toBeVisible();
+ const profileCardWidth = await card.evaluate(
+ (element) => element.clientWidth,
+ );
+ expect(profileCardWidth).toBe(610);
+
+ await page.getByTestId("onboarding-display-name").fill("Morty QA");
+ await page.getByTestId("onboarding-next").click();
+ await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
+
+ const modeShell = page.getByTestId("onboarding-avatar-mode-content-shell");
+ const cardWidths = [await card.evaluate((element) => element.clientWidth)];
+ expect(cardWidths[0]).toBeGreaterThan(profileCardWidth);
+ const skipBox = await page.getByTestId("onboarding-skip").boundingBox();
+ const nextBox = await page.getByTestId("onboarding-next").boundingBox();
+ if (!skipBox || !nextBox) throw new Error("Avatar navigation is missing.");
+ expect(skipBox.y).toBeCloseTo(nextBox.y, 0);
+ expect(skipBox.x + skipBox.width).toBeLessThanOrEqual(nextBox.x);
+ const imageShellBox = await modeShell.boundingBox();
+ const uploadBox = await page
+ .getByTestId("onboarding-avatar-upload")
+ .boundingBox();
+ const urlBox = await page
+ .getByTestId("onboarding-avatar-url")
+ .locator("..")
+ .boundingBox();
+ if (!imageShellBox || !uploadBox || !urlBox) {
+ throw new Error("Image controls are missing.");
+ }
+ expect(imageShellBox.height).toBeCloseTo(420, 0);
+ expect(uploadBox.height + 12 + urlBox.height).toBeCloseTo(
+ imageShellBox.height,
+ 0,
+ );
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOTS}/01-avatar-actions.png` });
+
+ await page.getByTestId("onboarding-avatar-mode-emoji").click();
+ cardWidths.push(await card.evaluate((element) => element.clientWidth));
+
+ await page.getByTestId("onboarding-avatar-mode-animated").click();
+ cardWidths.push(await card.evaluate((element) => element.clientWidth));
+ expect(new Set(cardWidths).size).toBe(1);
+
+ const iphoneBox = await page
+ .getByTestId("onboarding-avatar-animated-camera-iphone")
+ .boundingBox();
+ const computerBox = await page
+ .getByTestId("onboarding-avatar-animated-camera-computer")
+ .boundingBox();
+ if (!iphoneBox || !computerBox) {
+ throw new Error("Animated camera options are missing.");
+ }
+ expect(iphoneBox.x).toBeCloseTo(computerBox.x, 0);
+ expect(iphoneBox.width).toBeCloseTo(computerBox.width, 0);
+ expect(iphoneBox.y + iphoneBox.height).toBeLessThan(computerBox.y);
+ expect(iphoneBox.height).toBeCloseTo(computerBox.height, 0);
+ expect(iphoneBox.height + 12 + computerBox.height).toBeCloseTo(420, 0);
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOTS}/02-avatar-animated.png` });
+
+ await page.getByTestId("onboarding-avatar-animated-camera-computer").click();
+ const recordButton = page.getByTestId("onboarding-avatar-animated-record");
+ await expect(recordButton).toBeVisible({ timeout: 10_000 });
+ await waitForAnimations(page);
+ const liveShellBox = await modeShell.boundingBox();
+ const liveIphoneBox = await page
+ .getByTestId("onboarding-avatar-animated-camera-iphone")
+ .boundingBox();
+ const liveComputerBox = await page
+ .getByTestId("onboarding-avatar-animated-camera-computer")
+ .boundingBox();
+ const recordBox = await recordButton.boundingBox();
+ if (!liveShellBox || !liveIphoneBox || !liveComputerBox || !recordBox) {
+ throw new Error("Live animated-avatar controls are missing.");
+ }
+ expect(liveShellBox.height).toBeCloseTo(420, 0);
+ expect(liveIphoneBox.height).toBeCloseTo(liveComputerBox.height, 0);
+ expect(liveIphoneBox.height).toBeLessThan(iphoneBox.height);
+ expect(recordBox.y + recordBox.height).toBeLessThanOrEqual(
+ liveShellBox.y + liveShellBox.height,
+ );
+ await page.screenshot({ path: `${SHOTS}/02b-avatar-animated-live.png` });
+
+ await page.getByTestId("onboarding-avatar-mode-emoji").click();
+ await selectFirstEmojiFromPicker(page);
+ await page.getByTestId("onboarding-avatar-custom-color").click();
+ const spectrum = page.getByTestId("onboarding-avatar-custom-color-spectrum");
+ await expect(spectrum).toBeVisible();
+ await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
+ const spectrumBox = await spectrum.boundingBox();
+ if (!spectrumBox) throw new Error("Custom color spectrum is missing.");
+ expect(spectrumBox.height).toBeGreaterThan(300);
+
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOTS}/03-avatar-compact-color.png` });
});
test("avatar step skip button completes community profile setup", async ({
diff --git a/desktop/tests/e2e/onboarding-backup.spec.ts b/desktop/tests/e2e/onboarding-backup.spec.ts
index 4b9040bd2b9..e6e00ac2b71 100644
--- a/desktop/tests/e2e/onboarding-backup.spec.ts
+++ b/desktop/tests/e2e/onboarding-backup.spec.ts
@@ -7,25 +7,75 @@ import {
startWindowFileDrag,
} from "../helpers/fileDrag";
-async function enterMachineBackup(page: import("@playwright/test").Page) {
- await installMockBridge(page, undefined, {
+async function enterMachineBackup(
+ page: import("@playwright/test").Page,
+ mock?: Parameters[1],
+) {
+ await installMockBridge(page, mock, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
await page.getByRole("button", { name: "Create a new identity key" }).click();
+ await page.getByRole("button", { name: "Create my private key" }).click();
}
-async function openBackupOptions(page: import("@playwright/test").Page) {
- await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
- await page.getByTestId("backup-options-link").click();
+test("fresh-key path explains the identity key before creating it", async ({
+ page,
+}) => {
+ await installMockBridge(page, undefined, {
+ skipCommunitySeed: true,
+ skipOnboardingSeed: true,
+ });
+ await page.goto("/");
+
+ await page.getByRole("button", { name: "Create a new identity key" }).click();
+
+ await expect(page.getByTestId("onboarding-content-card")).toBeVisible();
await expect(
- page.getByTestId("onboarding-page-backup-options"),
+ page.getByRole("heading", { name: "Create a private identity key" }),
).toBeVisible();
-}
+ await expect(
+ page.getByTestId("onboarding-key-guidance").locator("p"),
+ ).toHaveText([
+ "Stored securely on this device",
+ "Never share it—anyone with this key can sign in as you",
+ "Use a secure backup to recover your account",
+ ]);
+ await expect(page.getByTestId("onboarding-page-backup")).toHaveCount(0);
+
+ await page.getByRole("button", { name: "Create my private key" }).click();
+ await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
+});
+
+test("identity creation failures stay visible on the intro page", async ({
+ page,
+}) => {
+ await installMockBridge(
+ page,
+ {
+ identityReadErrorAfter: {
+ message: "Keychain is unavailable",
+ successfulReads: 1,
+ },
+ },
+ { skipCommunitySeed: true, skipOnboardingSeed: true },
+ );
+ await page.goto("/");
+ await page.getByRole("button", { name: "Create a new identity key" }).click();
+ await page.getByRole("button", { name: "Create my private key" }).click();
+
+ await expect(page.getByTestId("identity-key-create-error")).toContainText(
+ "Keychain is unavailable",
+ );
+ await expect(
+ page.getByRole("button", { name: "Create my private key" }),
+ ).toBeEnabled();
+ await expect(page.getByTestId("onboarding-page-key-intro")).toBeVisible();
+});
async function openPasswordBackup(page: import("@playwright/test").Page) {
- await openBackupOptions(page);
+ await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
await page.getByTestId("backup-option-password").click();
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
}
@@ -65,7 +115,7 @@ test("backup step appears on fresh-key path after profile submit", async ({
await expect(
page.getByRole("heading", {
- name: "Your unique identity key has been created",
+ name: "Your private identity key",
}),
).toBeVisible();
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
@@ -73,11 +123,10 @@ test("backup step appears on fresh-key path after profile submit", async ({
});
// ---------------------------------------------------------------------------
-// Key-created view: masked key with reveal toggle. Backup options open the
-// dark security view; the raw key is fetched only on explicit reveal/copy.
+// Key-created view: masked key with explicit Reveal and Copy actions.
// ---------------------------------------------------------------------------
-test("key view reveals explicitly; options copy explicitly", async ({
+test("key view keeps the secret masked until Reveal and Copy stay explicit", async ({
page,
}) => {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
@@ -85,41 +134,53 @@ test("key view reveals explicitly; options copy explicitly", async ({
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
- // Masked by default: decorative mask only, no key material in the DOM.
const key = page.getByTestId("backup-key-value");
await expect(key).toBeVisible();
- await expect(key).toHaveClass(/blur/);
- await expect(key).not.toContainText("nsec1");
+ await expect(key).not.toContainText("nsec1mock");
expect(await invokedCommands(page)).not.toContain("get_nsec");
- // Reveal fetches the key; box must not reflow (same-length monospace mask).
- await page.getByTestId("backup-key-reveal-toggle").click();
+ // The credential enters the DOM only after an explicit reveal action.
+ await page.getByTestId("backup-reveal-key").click();
await expect(key).toContainText("nsec1mock");
- await expect(key).toHaveClass(/select-text/);
-
- await waitForAnimations(page);
- await page.screenshot({ path: `${SHOTS}/02-backup-chooser-revealed.png` });
-
- // Hide again.
- await page.getByTestId("backup-key-reveal-toggle").click();
- await expect(key).not.toContainText("nsec1");
+ expect(await invokedCommands(page)).toContain("get_nsec");
+ await page.getByTestId("backup-reveal-key").click();
+ await expect(key).not.toContainText("nsec1mock");
- // Copy is available only after opening the dark backup-options view.
- await page.getByTestId("backup-options-link").click();
- await expect(
- page.getByTestId("onboarding-page-backup-options"),
- ).toBeVisible();
- await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
+ // Copy remains a separate explicit action and may reuse the in-memory key.
await page.getByTestId("backup-copy-key").click();
+ await expect(page.getByTestId("backup-copy-key")).toContainText(
+ "Copied to clipboard",
+ );
await expect
.poll(async () => invokedCommands(page))
.toContain("copy_text_to_clipboard");
- expect(await invokedCommands(page)).toContain("get_nsec");
+ await expect(key).not.toContainText("nsec1mock");
+
+ // The backup action gains a subtle surface on hover without shifting its
+ // content or changing the resting state.
+ const backupOption = page.getByTestId("backup-option-password");
+ const backupOptionBox = await backupOption.boundingBox();
+ const [backupIconBox, backupChevronBox] = await Promise.all([
+ backupOption.locator("svg").first().boundingBox(),
+ backupOption.locator("svg").last().boundingBox(),
+ ]);
+ if (!backupOptionBox || !backupIconBox || !backupChevronBox) {
+ throw new Error("Could not measure locked-backup row padding");
+ }
+ expect(backupIconBox.x - backupOptionBox.x).toBeGreaterThanOrEqual(12);
+ expect(
+ backupOptionBox.x +
+ backupOptionBox.width -
+ (backupChevronBox.x + backupChevronBox.width),
+ ).toBeGreaterThanOrEqual(12);
+ await expect(backupOption).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
+ await backupOption.hover();
+ await expect(backupOption).not.toHaveCSS(
+ "background-color",
+ "rgba(0, 0, 0, 0)",
+ );
- // Return restores the yellow key-created view; its Next skips backup and
- // continues directly to setup.
- await page.getByTestId("backup-return-to-onboarding").click();
- await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
+ // The primary action continues directly to setup.
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
@@ -127,8 +188,7 @@ test("key view reveals explicitly; options copy explicitly", async ({
// ---------------------------------------------------------------------------
// Encrypted download path ("Backup your key" step): password → encrypt
-// locally → native save → saved confirmation. The raw key must never be
-// fetched on this path.
+// locally → native save → saved confirmation.
// ---------------------------------------------------------------------------
test("download happy path: generated password, encrypt, native save, Next", async ({
@@ -136,8 +196,8 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
}) => {
await enterMachineBackup(page);
- // Password backup opens from the dark options state without adding an
- // onboarding progress step.
+ // Password backup stays inside the onboarding card without adding a generic
+ // Next action.
await openPasswordBackup(page);
// The password field starts empty; the create button sits in the footer's
@@ -150,7 +210,28 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
const passwordPanel = page.getByTestId("backup-password-panel");
await expect(passwordPanel).toBeVisible();
await expect(passwordPanel).not.toHaveClass(/buzz-card-textured/);
- await expect(passwordPanel).toHaveCSS("padding-left", "24px");
+ await expect(passwordPanel).toHaveCSS("padding-left", "0px");
+ await expect(page.getByTestId("backup-password-timeline")).toHaveCount(0);
+ await expect(
+ passwordPanel.getByText("Password", { exact: true }),
+ ).toBeVisible();
+ const subtitle = page.getByText(
+ "This creates a password-protected file with your private key. Remember, Buzz can’t recover your key if you lose it.",
+ );
+ const passwordLabel = passwordPanel.getByText("Password", { exact: true });
+ const [subtitleBox, passwordLabelBox] = await Promise.all([
+ subtitle.boundingBox(),
+ passwordLabel.boundingBox(),
+ ]);
+ expect(subtitleBox).not.toBeNull();
+ expect(passwordLabelBox).not.toBeNull();
+ expect(
+ (passwordLabelBox?.y ?? 0) -
+ ((subtitleBox?.y ?? 0) + (subtitleBox?.height ?? 0)),
+ ).toBeLessThanOrEqual(96);
+ await expect(input).toHaveCSS("height", "48px");
+ await expect(input).toHaveCSS("text-align", "left");
+ await expect(input).toHaveCSS("background-color", "rgb(249, 249, 249)");
// The inset refresh icon opens the generator popover and immediately
// fills the field (mock default: 3 words, spaces).
@@ -177,8 +258,7 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
await page.screenshot({ path: `${SHOTS}/03-backup-download-passphrase.png` });
// Encryption may still be running when the user commits the download. The
- // explicit click queues the native save without exposing the password or
- // fetching the raw key.
+ // explicit click queues the native save without exposing the password.
await page.keyboard.press("Escape");
await expect(page.getByTestId("backup-passphrase-separator")).toHaveCount(0);
@@ -186,20 +266,34 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
await page.getByTestId("encrypted-backup-create").click();
// Only a successful save (the mock "picks" a path) advances to the
- // "Optionally, test your backup" flow: a select-file button for the saved file
+ // "Your backup is ready" flow: a select-file button for the saved file
// (a composer-style drop overlay takes over the card while a file drag is
// over the window), then the password to unlock it.
await expect(
- page.getByRole("heading", { name: "Optionally, test your backup" }),
+ page.getByRole("heading", { name: "Your backup is ready" }),
).toBeVisible();
const dropzone = page.getByTestId("backup-test-dropzone");
await expect(dropzone).toBeVisible();
+ await expect(dropzone).toHaveText("Test your backup");
+ await expect(dropzone).toHaveClass(/w-full/);
+ const [dropzoneBox, backupPanelBox] = await Promise.all([
+ dropzone.boundingBox(),
+ passwordPanel.boundingBox(),
+ ]);
+ expect(dropzoneBox).not.toBeNull();
+ expect(backupPanelBox).not.toBeNull();
+ expect(dropzoneBox?.width ?? 0).toBeGreaterThanOrEqual(
+ (backupPanelBox?.width ?? 0) * 0.95,
+ );
await expect(
- page.getByRole("button", { name: "Re-download backup" }),
+ page.getByRole("button", { name: "Download backup again" }),
).toBeVisible();
+ await expect(page.getByTestId("encrypted-backup-save-copy")).toHaveClass(
+ /w-full/,
+ );
// The optional security subview has no onboarding Next action. Returning to
- // the yellow key view is the single exit throughout the ceremony.
+ // the key-created view is the single exit throughout the ceremony.
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
await expect(page.getByTestId("backup-return-to-onboarding")).toBeVisible();
@@ -253,13 +347,14 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/06-backup-test-success.png` });
- // The download path must never have fetched the raw key.
+ // Creating an encrypted backup uses a dedicated native command and does not
+ // fetch the raw identity key into the renderer.
const commands = await invokedCommands(page);
expect(commands).not.toContain("get_nsec");
expect(commands).toContain("create_ncryptsec_backup");
// Completion remains inside the optional security subview. Return to the
- // yellow key view, whose standard Next action continues onboarding.
+ // key-created view, whose standard Next action continues onboarding.
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
await page.getByTestId("backup-return-to-onboarding").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
@@ -268,14 +363,46 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
});
-test("security view returns to the yellow onboarding view", async ({
+test("pending backup action collapses to an in-button loader", async ({
+ page,
+}) => {
+ await enterMachineBackup(page, { backupEncryptionDelayMs: 3_000 });
+ await openPasswordBackup(page);
+
+ await page
+ .getByTestId("backup-passphrase-input")
+ .fill("mock-horse-battery-staple");
+ const create = page.getByTestId("encrypted-backup-create");
+ const readyButtonBox = await create.boundingBox();
+ await create.click();
+
+ await expect(create).toHaveAttribute("aria-busy", "true");
+ await expect(create).toHaveAccessibleName("Encrypting your key");
+ await expect(create.getByTestId("encrypted-backup-encrypting")).toBeVisible();
+ const pendingButtonBox = await create.boundingBox();
+ if (!readyButtonBox || !pendingButtonBox) {
+ throw new Error("Could not measure the encrypted-backup action button");
+ }
+ expect(pendingButtonBox.height).toBeCloseTo(readyButtonBox.height, 0);
+ expect(pendingButtonBox.width).toBeCloseTo(pendingButtonBox.height, 0);
+ expect(pendingButtonBox.width).toBeLessThan(readyButtonBox.width);
+ await expect(
+ page.getByText("Encrypting your password", { exact: true }),
+ ).toHaveCount(0);
+
+ await expect(
+ page.getByRole("heading", { name: "Your backup is ready" }),
+ ).toBeVisible();
+});
+
+test("security view returns to the identity-key onboarding view", async ({
page,
}) => {
await enterMachineBackup(page);
await openPasswordBackup(page);
await expect(page.getByTestId("backup-passphrase-input")).toBeVisible();
- await expect(page.getByTestId("onboarding-step-dots")).toHaveCount(0);
+ await expect(page.getByTestId("onboarding-step-dots")).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
await page.getByTestId("backup-return-to-onboarding").click();
@@ -294,7 +421,7 @@ test("returning to onboarding resets password-backup progress", async ({
await input.fill("mock-horse-battery-staple");
await page.getByTestId("encrypted-backup-create").click();
await expect(
- page.getByRole("heading", { name: "Optionally, test your backup" }),
+ page.getByRole("heading", { name: "Your backup is ready" }),
).toBeVisible();
await page.getByTestId("backup-return-to-onboarding").click();
@@ -304,7 +431,7 @@ test("returning to onboarding resets password-backup progress", async ({
// backup session so no password or completed state leaks across navigation.
await openPasswordBackup(page);
await expect(
- page.getByRole("heading", { name: "Backup your key with a password" }),
+ page.getByRole("heading", { name: "Create a secure backup file" }),
).toBeVisible();
await expect(page.getByTestId("backup-passphrase-input")).toHaveValue("");
await expect(page.getByTestId("encrypted-backup-create")).toBeDisabled();
@@ -328,13 +455,17 @@ test("typed password requires 12 characters", async ({ page }) => {
await expect(create).toBeEnabled();
});
-test("backup step back button returns to machine identity choice", async ({
+test("backup step back button returns through key guidance to identity choice", async ({
page,
}) => {
await enterMachineBackup(page);
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await page.getByTestId("onboarding-back").click();
+ await expect(
+ page.getByRole("heading", { name: "Create a private identity key" }),
+ ).toBeVisible();
+ await page.getByTestId("onboarding-back").click();
// Backing out preserves the loaded key — primary CTA continues setup rather
// than minting another identity (#2318).
@@ -347,10 +478,10 @@ test("backup step back button returns to machine identity choice", async ({
});
// ---------------------------------------------------------------------------
-// B4: Error path coverage (reveal/copy)
+// B4: Error path coverage (copy)
// ---------------------------------------------------------------------------
-test("reveal shows inline error when get_nsec fails and Next still advances", async ({
+test("copy shows inline error when get_nsec fails and Next still advances", async ({
page,
}) => {
await installMockBridge(
@@ -360,9 +491,11 @@ test("reveal shows inline error when get_nsec fails and Next still advances", as
);
await page.goto("/");
await page.getByRole("button", { name: "Create a new identity key" }).click();
+ await page.getByRole("button", { name: "Create my private key" }).click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
- await page.getByTestId("backup-key-reveal-toggle").click();
+ await page.getByTestId("backup-key-well").hover();
+ await page.getByTestId("backup-copy-key").click();
await expect(page.getByTestId("backup-copy-error")).toBeVisible();
// Keychain failure does not trap the user: Next still skips backup and
@@ -372,8 +505,9 @@ test("reveal shows inline error when get_nsec fails and Next still advances", as
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
});
-test("reveal retry succeeds after initial failure", async ({ page }) => {
- // First call fails, second succeeds (sequenced via nsecErrors).
+test("Copy retries after an initial key read fails", async ({ page }) => {
+ await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
+ // Explicit reveal fails first; explicit copy retries and succeeds.
await installMockBridge(
page,
{ nsecErrors: ["Keychain locked", null] },
@@ -381,12 +515,22 @@ test("reveal retry succeeds after initial failure", async ({ page }) => {
);
await page.goto("/");
await page.getByRole("button", { name: "Create a new identity key" }).click();
+ await page.getByRole("button", { name: "Create my private key" }).click();
- await page.getByTestId("backup-key-reveal-toggle").click();
+ // Reveal consumes the first failure without exposing a secret.
+ await page.getByTestId("backup-reveal-key").click();
await expect(page.getByTestId("backup-copy-error")).toBeVisible();
+ await expect(page.getByTestId("backup-key-value")).not.toContainText(
+ "nsec1mock",
+ );
- // Retry — second call succeeds and clears the error.
- await page.getByTestId("backup-key-reveal-toggle").click();
- await expect(page.getByTestId("backup-key-value")).toContainText("nsec1mock");
+ // Copy retries the read and clears the error without revealing the DOM value.
+ await page.getByTestId("backup-copy-key").click();
+ await expect(page.getByTestId("backup-copy-key")).toContainText(
+ "Copied to clipboard",
+ );
+ await expect(page.getByTestId("backup-key-value")).not.toContainText(
+ "nsec1mock",
+ );
await expect(page.getByTestId("backup-copy-error")).not.toBeVisible();
});
diff --git a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts
index 1a426438ea9..a54cada5c20 100644
--- a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts
+++ b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts
@@ -1,4 +1,4 @@
-import { expect, test } from "@playwright/test";
+import { expect, type Locator, type Page, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
@@ -10,14 +10,204 @@ const BLANK_TYLER_IDENTITY = {
};
const SHOT_DIR = "test-results/onboarding-docked-cta";
+const COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY =
+ "buzz-community-onboarding-transaction.v1";
const NCRYPTSEC =
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
test.use({ viewport: { width: 1280, height: 800 } });
+async function expectSharedCardGeometry(page: Page, expectedWidth = 610) {
+ const geometry = await page
+ .getByTestId("onboarding-content-card")
+ .evaluate((element) => {
+ const rect = element.getBoundingClientRect();
+ const styles = window.getComputedStyle(element);
+ return {
+ borderRadius: styles.borderRadius,
+ height: rect.height,
+ paddingBottom: styles.paddingBottom,
+ paddingLeft: styles.paddingLeft,
+ paddingRight: styles.paddingRight,
+ paddingTop: styles.paddingTop,
+ width: rect.width,
+ };
+ });
+
+ expect(geometry.width).toBeCloseTo(expectedWidth, 0);
+ expect(geometry.height).toBeCloseTo(664, 0);
+ expect(geometry.borderRadius).toBe("32px");
+ expect(geometry.paddingTop).toBe("48px");
+ expect(geometry.paddingRight).toBe("48px");
+ expect(geometry.paddingBottom).toBe("48px");
+ expect(geometry.paddingLeft).toBe("48px");
+}
+
+async function expectUsesFullCardWidth(element: Locator) {
+ const geometry = await element.evaluate((node) => {
+ const available = node.closest(
+ ".buzz-onboarding-transition-content",
+ );
+ if (!available) throw new Error("Onboarding content column is missing");
+ const availableBox = available.getBoundingClientRect();
+ const elementBox = node.getBoundingClientRect();
+ return {
+ availableLeft: availableBox.left,
+ availableWidth: availableBox.width,
+ elementLeft: elementBox.left,
+ elementWidth: elementBox.width,
+ };
+ });
+ expect(geometry.elementLeft).toBeCloseTo(geometry.availableLeft, 0);
+ expect(geometry.elementWidth).toBeCloseTo(geometry.availableWidth, 0);
+}
+
+async function expectProfileFooterMatchesContentGutters(page: Page) {
+ const geometry = await page.evaluate(() => {
+ const input = document
+ .querySelector("#onboarding-display-name")
+ ?.getBoundingClientRect();
+ const back = document
+ .querySelector('[data-testid="onboarding-back"]')
+ ?.getBoundingClientRect();
+ const next = document
+ .querySelector('[data-testid="onboarding-next"]')
+ ?.getBoundingClientRect();
+ if (!input || !back || !next) {
+ throw new Error("Profile controls are missing");
+ }
+ return {
+ backLeft: back.left,
+ inputLeft: input.left,
+ inputRight: input.right,
+ nextRight: next.right,
+ };
+ });
+
+ expect(geometry.backLeft).toBeCloseTo(geometry.inputLeft, 0);
+ expect(geometry.nextRight).toBeCloseTo(geometry.inputRight, 0);
+}
+
+async function expectHorizontalCardTransition(
+ page: Page,
+ pageTestId: string,
+ expectedDirection: "forward" | "backward",
+) {
+ const transition = page
+ .getByTestId(pageTestId)
+ .locator(".buzz-onboarding-transition-line")
+ .first();
+ await expect(transition).toHaveAttribute(
+ "data-onboarding-direction",
+ expectedDirection,
+ );
+
+ const motion = await transition.evaluate((line) => {
+ const content = line.querySelector(
+ ":scope > .buzz-onboarding-transition-content",
+ );
+ if (!content) throw new Error("Onboarding transition content is missing");
+ const frame = line.closest(".buzz-onboarding-step-frame");
+ if (!frame) throw new Error("Onboarding transition frame is missing");
+
+ const animationName = window.getComputedStyle(content).animationName;
+ const keyframes: Array<{ transform: string; x: number; y: number }> = [];
+ const visitRules = (rules: CSSRuleList) => {
+ for (const rule of Array.from(rules)) {
+ if (rule instanceof CSSKeyframesRule && rule.name === animationName) {
+ for (const frame of Array.from(rule.cssRules)) {
+ const transform = (frame as CSSKeyframeRule).style.transform;
+ const matrix = new DOMMatrixReadOnly(transform || "none");
+ keyframes.push({
+ transform: transform || "none",
+ x: matrix.m41,
+ y: matrix.m42,
+ });
+ }
+ continue;
+ }
+ if ("cssRules" in rule) {
+ try {
+ visitRules((rule as CSSGroupingRule).cssRules);
+ } catch {
+ // Cross-origin and unsupported grouping rules are irrelevant here.
+ }
+ }
+ }
+ };
+ for (const styleSheet of Array.from(document.styleSheets)) {
+ try {
+ visitRules(styleSheet.cssRules);
+ } catch {
+ // Ignore stylesheets whose rules the browser does not expose.
+ }
+ }
+
+ const activeFrames = line
+ .getAnimations({ subtree: true })
+ .flatMap((animation) => {
+ const effect = animation.effect;
+ if (!(effect instanceof KeyframeEffect)) return [];
+ const target = effect.target;
+ return effect
+ .getKeyframes()
+ .filter((frame) => frame.transform && frame.transform !== "none")
+ .map((frame) => {
+ const transform = String(frame.transform);
+ const matrix = new DOMMatrixReadOnly(transform);
+ return {
+ target:
+ target instanceof HTMLElement
+ ? target.className.toString()
+ : (target?.nodeName ?? "unknown"),
+ transform,
+ x: matrix.m41,
+ y: matrix.m42,
+ };
+ });
+ });
+
+ const frameRect = frame.getBoundingClientRect();
+ const lineRect = line.getBoundingClientRect();
+ const travel = 48;
+ const runway = {
+ backwardStart: lineRect.left - travel,
+ forwardEnd: lineRect.left + content.offsetWidth + travel,
+ frameLeft: frameRect.left,
+ frameRight: frameRect.right,
+ };
+
+ return { activeFrames, animationName, keyframes, runway };
+ });
+
+ expect(motion.animationName).toBe(
+ `buzz-onboarding-line-slide-${expectedDirection}`,
+ );
+ expect(motion.keyframes.length).toBeGreaterThanOrEqual(2);
+ for (const frame of [...motion.keyframes, ...motion.activeFrames]) {
+ expect(
+ Math.abs(frame.y),
+ `Unexpected vertical motion in ${frame.transform}`,
+ ).toBeLessThan(0.01);
+ }
+ const enteringFrame = motion.keyframes[0];
+ if (!enteringFrame) throw new Error("Transition entry frame is missing");
+ expect(enteringFrame.x).toBe(expectedDirection === "forward" ? 48 : -48);
+ if (expectedDirection === "forward") {
+ expect(motion.runway.forwardEnd).toBeLessThanOrEqual(
+ motion.runway.frameRight + 0.5,
+ );
+ } else {
+ expect(motion.runway.backwardStart).toBeGreaterThanOrEqual(
+ motion.runway.frameLeft - 0.5,
+ );
+ }
+}
+
test("machine onboarding: landing, backup, setup docked CTAs", async ({
page,
}) => {
+ await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
@@ -33,21 +223,40 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
await expect(
page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
- const importCard = page.getByTestId("nostr-import-card");
+ await expectHorizontalCardTransition(
+ page,
+ "machine-onboarding-gate",
+ "forward",
+ );
+ const importCard = page.getByTestId("onboarding-content-card");
await expect(importCard).toBeVisible();
+ await expectSharedCardGeometry(page);
await expect(page.getByLabel("Private key", { exact: true })).toBeVisible();
- // The production card uses a baked nine-slice texture: no runtime SVG
- // filter, measurement, or texture regeneration during resize.
- await expect(importCard).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
- await expect(importCard).toHaveCSS("border-top-width", "0px");
- await expect(importCard).toHaveCSS("border-image-repeat", "repeat");
- await expect(importCard).toHaveCSS("border-image-outset", "96px");
- // Icon SVGs (e.g. the reveal toggle) are fine; a filter would mean the
- // texture regressed to the runtime SVG pipeline.
+ await expectUsesFullCardWidth(page.getByTestId("nostr-import-nsec-input"));
+ await expect(importCard).toHaveCSS("background-color", "rgb(255, 255, 255)");
+ await expect(page.getByTestId("nostr-import-card")).toHaveCount(0);
await expect(importCard.locator("svg filter")).toHaveCount(0);
+ const onboardingBack = page.getByTestId("onboarding-back");
+ await expect(onboardingBack).toHaveCSS("width", "52px");
+ await expect(onboardingBack).toHaveCSS("height", "52px");
+ await expect(onboardingBack.locator("svg")).toHaveCSS("width", "24px");
+ await expect(onboardingBack.locator("svg")).toHaveCSS("height", "24px");
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/01b-enter-key.png` });
+ await page.getByTestId("nostr-import-file-button").click();
+ await expect(page.getByTestId("backup-recovery-dialog")).toBeVisible();
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOT_DIR}/01c-backup-file-sheet.png` });
+ await page.getByRole("button", { name: "Back", exact: true }).click();
+
+ await page.getByTestId("nostr-import-phone-link").click();
+ await expect(page.getByTestId("phone-recovery-dialog")).toBeVisible();
+ await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOT_DIR}/01d-phone-recovery-sheet.png` });
+ await page.getByRole("button", { name: "Back", exact: true }).click();
+
await page.getByTestId("nostr-import-nsec-input").fill(NCRYPTSEC);
await expect(
page.getByRole("heading", { name: "Unlock your account" }),
@@ -57,7 +266,7 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
await expect(page.getByTestId("restore-unlock-icon")).toBeVisible();
await expect(page.getByTestId("nostr-import-passphrase")).toBeFocused();
await waitForAnimations(page);
- await page.screenshot({ path: `${SHOT_DIR}/01c-restore-backup.png` });
+ await page.screenshot({ path: `${SHOT_DIR}/01e-restore-backup.png` });
// The first Back returns to key selection; the second leaves import.
await page.getByRole("button", { name: "Back", exact: true }).click();
@@ -67,51 +276,120 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
page.getByRole("button", { name: "Create a new identity key" }),
).toBeVisible();
await page.getByRole("button", { name: "Create a new identity key" }).click();
+ await expect(
+ page.getByRole("heading", { name: "Create a private identity key" }),
+ ).toBeVisible();
+ await expectHorizontalCardTransition(
+ page,
+ "onboarding-page-key-intro",
+ "forward",
+ );
+ await expectUsesFullCardWidth(page.getByTestId("onboarding-key-guidance"));
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOT_DIR}/02-key-introduction.png` });
+ await page.getByRole("button", { name: "Create my private key" }).click();
await expect(
page.getByRole("heading", {
- name: "Your unique identity key has been created",
+ name: "Your private identity key",
}),
).toBeVisible();
+ await expectHorizontalCardTransition(
+ page,
+ "onboarding-page-backup",
+ "forward",
+ );
+ await expectSharedCardGeometry(page);
+ const keyGeometry = await page.evaluate(() => {
+ const keyWell = document
+ .querySelector('[data-testid="backup-key-well"]')
+ ?.getBoundingClientRect();
+ const keyWellStyles = window.getComputedStyle(
+ document.querySelector('[data-testid="backup-key-well"]') ??
+ document.documentElement,
+ );
+ const backupRow = document
+ .querySelector('[data-testid="backup-option-password"]')
+ ?.getBoundingClientRect();
+ const copyButton = document
+ .querySelector('[data-testid="backup-copy-key"]')
+ ?.getBoundingClientRect();
+ const keyValue = document.querySelector('[data-testid="backup-key-value"]');
+ const keyRows = keyValue
+ ? (() => {
+ const range = document.createRange();
+ range.selectNodeContents(keyValue);
+ return new Set(
+ Array.from(range.getClientRects()).map((rect) =>
+ Math.round(rect.top),
+ ),
+ ).size;
+ })()
+ : 0;
+ return {
+ backupRowHeight: backupRow?.height ?? 0,
+ backupRowWidth: backupRow?.width ?? 0,
+ copyButtonHeight: copyButton?.height ?? 0,
+ keyRows,
+ keyWellPaddingLeft: keyWellStyles.paddingLeft,
+ keyWellPaddingRight: keyWellStyles.paddingRight,
+ keyWellHeight: keyWell?.height ?? 0,
+ keyWellWidth: keyWell?.width ?? 0,
+ };
+ });
+ expect(keyGeometry.keyWellWidth).toBeCloseTo(512, 0);
+ expect(keyGeometry.keyWellHeight).toBeCloseTo(122, 0);
+ expect(keyGeometry.keyRows).toBe(2);
+ expect(keyGeometry.keyWellPaddingLeft).toBe("16px");
+ expect(keyGeometry.keyWellPaddingRight).toBe("16px");
+ expect(keyGeometry.copyButtonHeight).toBeCloseTo(32, 0);
+ expect(keyGeometry.backupRowWidth).toBeCloseTo(512, 0);
+ expect(keyGeometry.backupRowHeight).toBeCloseTo(48, 0);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02-backup.png` });
- // The key stays masked behind an explicit reveal toggle.
- await expect(page.getByTestId("backup-key-value")).toBeVisible();
-
- // Reveal the key: box must not reflow (same-length monospace mask).
- await page.getByTestId("backup-key-reveal-toggle").click();
- await expect(page.getByTestId("backup-key-value")).toHaveClass(/select-text/);
+ const backupOption = page.getByTestId("backup-option-password");
+ await backupOption.hover();
+ await expect(backupOption).not.toHaveCSS(
+ "background-color",
+ "rgba(0, 0, 0, 0)",
+ );
await waitForAnimations(page);
- await page.screenshot({ path: `${SHOT_DIR}/02b-backup-revealed.png` });
+ await page.screenshot({ path: `${SHOT_DIR}/02a-backup-option-hover.png` });
- // Backup options leave the yellow flow for the dark security view without
- // adding a progress step or a generic Next action.
- await page.getByTestId("backup-options-link").click();
- await expect(
- page.getByTestId("onboarding-page-backup-options"),
- ).toBeVisible();
- await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
- const optionPanels = page.getByTestId("backup-option-panel");
- await expect(optionPanels).toHaveCount(3);
- await expect(
- page.getByTestId("backup-options").locator(".buzz-card-textured"),
- ).toHaveCount(0);
- await expect(optionPanels.first()).toHaveCSS("padding-left", "24px");
- const titleTops = await optionPanels
- .locator("span.text-lg")
- .evaluateAll((titles) =>
- titles.map((title) => title.getBoundingClientRect().top),
- );
- expect(Math.max(...titleTops) - Math.min(...titleTops)).toBeLessThan(1);
+ // The reusable secret stays out of the DOM until the user explicitly asks
+ // to reveal it. Copy is a separate action and leaves the rendered value
+ // masked.
+ const keyValue = page.getByTestId("backup-key-value");
+ const revealButton = page.getByTestId("backup-reveal-key");
+ const copyButton = page.getByTestId("backup-copy-key");
+ await expect(keyValue).toBeVisible();
+ await expect(keyValue).not.toContainText("nsec1mock");
+ await expect(revealButton).toHaveAccessibleName("Reveal private key");
+ await revealButton.click();
+ await expect(keyValue).toContainText("nsec1mock");
+ await expect(revealButton).toHaveAccessibleName("Hide private key");
+ await revealButton.click();
+ await expect(keyValue).not.toContainText("nsec1mock");
+ await expect(copyButton).toBeEnabled();
+ await copyButton.click();
+ await expect(copyButton).toContainText("Copied to clipboard");
+ await expect(keyValue).not.toContainText("nsec1mock");
await waitForAnimations(page);
- await page.screenshot({ path: `${SHOT_DIR}/02c-backup-options.png` });
+ await page.screenshot({ path: `${SHOT_DIR}/02b-backup-copy.png` });
+ // The locked-backup action is part of the generated-key sheet.
await page.getByTestId("backup-option-password").click();
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
+ await expectHorizontalCardTransition(
+ page,
+ "onboarding-page-download",
+ "forward",
+ );
const passwordPanel = page.getByTestId("backup-password-panel");
await expect(passwordPanel).toBeVisible();
+ await expectUsesFullCardWidth(passwordPanel);
await expect(passwordPanel).not.toHaveClass(/buzz-card-textured/);
- await expect(passwordPanel).toHaveCSS("padding-left", "24px");
+ await expect(passwordPanel).toHaveCSS("padding-left", "0px");
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02d-backup-password.png` });
@@ -125,12 +403,174 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
await page.getByTestId("backup-return-to-onboarding").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
+ await expectHorizontalCardTransition(
+ page,
+ "onboarding-page-backup",
+ "backward",
+ );
await page.getByTestId("onboarding-next").click();
await expect(
- page.getByRole("heading", { name: "Set up your agent harnesses" }),
+ page.getByRole("heading", { name: "Connect your AI provider" }),
).toBeVisible();
+ const subscriptionMethod = page.getByTestId(
+ "onboarding-harness-method-subscription",
+ );
+ const apiMethod = page.getByTestId("onboarding-harness-method-api");
+ await expect(subscriptionMethod).toContainText("Log in with a subscription");
+ await expect(apiMethod).toContainText("Use an API key");
+ await expect(subscriptionMethod).not.toHaveCSS(
+ "background-color",
+ "rgba(0, 0, 0, 0)",
+ );
+ await expectUsesFullCardWidth(subscriptionMethod);
+ await expectUsesFullCardWidth(apiMethod);
+ await expectSharedCardGeometry(page);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/03-setup.png` });
+
+ await page.getByTestId("onboarding-harness-method-subscription").click();
+ await expect(
+ page.getByRole("heading", { name: "Continue with an AI subscription" }),
+ ).toBeVisible();
+ await expectHorizontalCardTransition(page, "onboarding-page-2", "forward");
+ await expect(page.getByText(/More harnesses can be added in/)).toHaveCount(0);
+ await expect(page.getByTestId("onboarding-setup-skip")).toBeVisible();
+ await expect(page.getByTestId("onboarding-setup-next")).toHaveCount(0);
+ await expect(
+ page.getByText("CLI not detected", { exact: false }),
+ ).toHaveCount(0);
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOT_DIR}/03b-subscriptions.png` });
+
+ await page.getByTestId("onboarding-runtime-details-codex").click();
+ await expect(
+ page.getByTestId("onboarding-harness-setup-guide"),
+ ).toBeVisible();
+ const guideCard = page.getByTestId("onboarding-harness-setup-guide-card");
+ await expect(guideCard).toContainText("Codex");
+ await expect(guideCard).toContainText(
+ "Codex is not detected on this computer.",
+ );
+ await expect(
+ guideCard.getByTestId("onboarding-harness-open-setup-guide"),
+ ).toHaveText("Open guide");
+ await expect(
+ page.getByTestId("onboarding-runtime-install-codex"),
+ ).toHaveCount(0);
+ await expectHorizontalCardTransition(page, "onboarding-page-2", "forward");
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOT_DIR}/03c-harness-guide.png` });
+
+ await page.getByTestId("onboarding-back").click();
+ await expect(
+ page.getByRole("heading", { name: "Continue with an AI subscription" }),
+ ).toBeVisible();
+ await expectHorizontalCardTransition(page, "onboarding-page-2", "backward");
+ await page.getByTestId("onboarding-back").click();
+ await expect(
+ page.getByRole("heading", { name: "Connect your AI provider" }),
+ ).toBeVisible();
+ await expectHorizontalCardTransition(page, "onboarding-page-2", "backward");
+ await page.getByTestId("onboarding-harness-method-api").click();
+ await expect(
+ page.getByRole("heading", { name: "Connect with an API key" }),
+ ).toBeVisible();
+ await expect(
+ page.getByText(
+ "Choose your provider and enter an API key to connect to the Buzz harness.",
+ ),
+ ).toBeVisible();
+ await expectHorizontalCardTransition(
+ page,
+ "onboarding-page-config",
+ "forward",
+ );
+ await expect(page.getByTestId("global-agent-default-harness")).toHaveCount(0);
+ await expectUsesFullCardWidth(page.getByTestId("global-agent-provider"));
+ await expect(
+ page.getByTestId("onboarding-use-different-harness"),
+ ).toBeVisible();
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOT_DIR}/03d-api-buzz-model.png` });
+
+ await page.getByTestId("global-agent-provider").click();
+ await page.getByTestId("global-agent-provider-option-anthropic").click();
+ await expect(page.getByTestId("persona-provider-api-key")).toBeVisible();
+ await expect(
+ page.getByText("ANTHROPIC_API_KEY", { exact: true }),
+ ).toHaveCount(0);
+ await expect(page.getByTestId("global-agent-model")).toHaveCount(0);
+ await expect(
+ page.getByTestId("global-agent-thinking-effort-select"),
+ ).toHaveCount(0);
+ const providerBeforeKey = await page
+ .getByTestId("global-agent-provider")
+ .boundingBox();
+ const apiKeyBeforeKey = await page
+ .getByTestId("persona-provider-api-key")
+ .boundingBox();
+ await waitForAnimations(page);
+ await page.screenshot({
+ path: `${SHOT_DIR}/03d2-api-provider-selected.png`,
+ });
+
+ await page.getByTestId("persona-provider-api-key").fill("sk-test-key");
+ await expect(page.getByTestId("global-agent-model")).toHaveText(
+ "Claude Opus 4.6",
+ );
+ const providerAfterKey = await page
+ .getByTestId("global-agent-provider")
+ .boundingBox();
+ const apiKeyAfterKey = await page
+ .getByTestId("persona-provider-api-key")
+ .boundingBox();
+ expect(providerBeforeKey).not.toBeNull();
+ expect(apiKeyBeforeKey).not.toBeNull();
+ expect(providerAfterKey).not.toBeNull();
+ expect(apiKeyAfterKey).not.toBeNull();
+ expect(
+ Math.abs((providerBeforeKey?.y ?? 0) - (providerAfterKey?.y ?? 0)),
+ ).toBeLessThan(2);
+ expect(
+ Math.abs((apiKeyBeforeKey?.y ?? 0) - (apiKeyAfterKey?.y ?? 0)),
+ ).toBeLessThan(2);
+ await waitForAnimations(page);
+ await page.screenshot({
+ path: `${SHOT_DIR}/03d3-api-key-entered.png`,
+ });
+
+ await page.getByTestId("onboarding-use-different-harness").click();
+ await expect(
+ page.getByRole("heading", { name: "Choose a harness" }),
+ ).toBeVisible();
+ await expectHorizontalCardTransition(page, "onboarding-page-2", "forward");
+ await expect(page.getByTestId("onboarding-runtime-buzz-agent")).toContainText(
+ "Recommended",
+ );
+ await expect(
+ page.getByTestId("onboarding-runtime-ready-buzz-agent"),
+ ).toHaveCount(0);
+ await page.getByTestId("onboarding-runtime-buzz-agent").hover();
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOT_DIR}/03e-api-harnesses.png` });
+
+ await page.getByTestId("onboarding-back").click();
+ await expect(
+ page.getByRole("heading", { name: "Connect with an API key" }),
+ ).toBeVisible();
+ await expect(page.getByTestId("global-agent-provider")).toHaveText(
+ "Anthropic",
+ );
+ await expect(page.getByTestId("persona-provider-api-key")).toHaveValue(
+ "sk-test-key",
+ );
+ await expectHorizontalCardTransition(
+ page,
+ "onboarding-page-config",
+ "backward",
+ );
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOT_DIR}/03f-api-return.png` });
});
test("machine key import remains usable in a short viewport", async ({
@@ -175,10 +615,8 @@ test("machine key import remains usable in a short viewport", async ({
expect(layout.scrollWidth).toBe(layout.clientWidth);
});
-test("backup options keep one-column geometry on narrow windows", async ({
- page,
-}) => {
- await page.setViewportSize({ width: 600, height: 700 });
+test("identity-key help stays inside the onboarding card", async ({ page }) => {
+ await page.setViewportSize({ width: 900, height: 650 });
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
@@ -186,26 +624,29 @@ test("backup options keep one-column geometry on narrow windows", async ({
await page.goto("/");
await page.getByRole("button", { name: "Create a new identity key" }).click();
await expect(
- page.getByRole("heading", {
- name: "Your unique identity key has been created",
- }),
+ page.getByRole("button", { name: "Learn how identity keys work" }),
).toBeVisible();
- await page.getByTestId("backup-options-link").click();
+ await page
+ .getByRole("button", { name: "Learn how identity keys work" })
+ .click();
- const panels = page.getByTestId("backup-option-panel");
- await expect(panels).toHaveCount(3);
+ const help = page.getByTestId("identity-key-help-dialog");
+ await expect(help).toBeVisible();
+ await expect(page.getByTestId("onboarding-step-dots")).toHaveCount(0);
+ await expect(
+ help.getByRole("heading", { name: "What’s an identity key?" }),
+ ).toBeVisible();
+ await expectUsesFullCardWidth(help.getByTestId("identity-key-help-body"));
await waitForAnimations(page);
- const geometry = await panels.evaluateAll((elements) => ({
+ await page.screenshot({ path: `${SHOT_DIR}/02f-identity-key-help.png` });
+ const geometry = await help.evaluate((element) => ({
clientWidth: document.documentElement.clientWidth,
- lefts: elements.map((element) => element.getBoundingClientRect().left),
- rights: elements.map((element) => element.getBoundingClientRect().right),
+ left: element.getBoundingClientRect().left,
+ right: element.getBoundingClientRect().right,
scrollWidth: document.documentElement.scrollWidth,
}));
- expect(new Set(geometry.lefts.map(Math.round)).size).toBe(1);
- expect(geometry.lefts.every((left) => left >= 0)).toBe(true);
- expect(geometry.rights.every((right) => right <= geometry.clientWidth)).toBe(
- true,
- );
+ expect(geometry.left).toBeGreaterThanOrEqual(0);
+ expect(geometry.right).toBeLessThanOrEqual(geometry.clientWidth);
expect(geometry.scrollWidth).toBe(geometry.clientWidth);
});
@@ -215,8 +656,11 @@ test("relay onboarding: profile and avatar docked CTAs", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
+ await expectSharedCardGeometry(page, 610);
+ await expect(page.getByTestId("onboarding-back")).toBeVisible();
await page.getByTestId("onboarding-display-name").fill("Ada Lovelace");
await waitForAnimations(page);
+ await expectProfileFooterMatchesContentGutters(page);
await page.screenshot({ path: `${SHOT_DIR}/04-profile.png` });
await page.getByTestId("onboarding-next").click();
@@ -227,3 +671,62 @@ test("relay onboarding: profile and avatar docked CTAs", async ({ page }) => {
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/05-avatar.png` });
});
+
+test("community onboarding: profile and starter-team cards", async ({
+ page,
+}) => {
+ await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
+ await page.addInitScript(
+ ({ pubkey, transactionStorageKey }) => {
+ window.localStorage.setItem(
+ `buzz-machine-onboarding-complete.v2:${pubkey}`,
+ "true",
+ );
+ const timestamp = new Date().toISOString();
+ window.localStorage.setItem(
+ transactionStorageKey,
+ JSON.stringify({
+ id: "screenshot-community-profile",
+ source: "first-community",
+ stage: "profile",
+ relayUrl: "ws://localhost:3000",
+ communityName: "Default",
+ communityId: "e2e-default-community",
+ addedCommunity: true,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ }),
+ );
+ },
+ {
+ pubkey: BLANK_TYLER_IDENTITY.pubkey,
+ transactionStorageKey: COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY,
+ },
+ );
+ await installMockBridge(
+ page,
+ { profileHasEvent: false },
+ {
+ relayWsUrl: "ws://localhost:3000",
+ skipOnboardingSeed: true,
+ },
+ );
+ await page.goto("/");
+
+ await expect(
+ page.getByRole("heading", { name: "Build your profile" }),
+ ).toBeVisible();
+ await expect(page.getByTestId("onboarding-content-card")).toBeVisible();
+ await expectSharedCardGeometry(page);
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOT_DIR}/06-community-profile.png` });
+
+ await page.getByTestId("community-profile-name-key").fill("Ada Lovelace");
+ await page.getByTestId("community-profile-next").click();
+ await expect(
+ page.getByRole("heading", { name: "Meet your starter team" }),
+ ).toBeVisible();
+ await expectSharedCardGeometry(page);
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOT_DIR}/07-starter-team.png` });
+});
diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts
index eba3b099316..ecca4f123e7 100644
--- a/desktop/tests/e2e/onboarding.spec.ts
+++ b/desktop/tests/e2e/onboarding.spec.ts
@@ -731,7 +731,7 @@ test("fresh existing-identity path leads with private-key recovery", async ({
await expect(
page.getByText("Paste your private key to sign in to Buzz."),
).toBeVisible();
- await expect(page.getByTestId("nostr-import-card")).toBeVisible();
+ await expect(page.getByTestId("onboarding-content-card")).toBeVisible();
await expect(page.getByTestId("nostr-import-file-button")).toHaveText(
"backup file",
);
@@ -795,17 +795,17 @@ test("fresh existing-identity path leads with private-key recovery", async ({
);
});
await expect(backupDrop).toHaveCount(0);
- await expect(page.getByTestId("nostr-import-card")).toBeVisible();
- await backupDialog.getByRole("button", { name: "Close" }).click();
+ await expect(page.getByTestId("onboarding-content-card")).toBeVisible();
+ await page.getByRole("button", { name: "Back", exact: true }).click();
await page.getByTestId("nostr-import-phone-link").click();
const phoneDialog = page.getByTestId("phone-recovery-dialog");
await expect(phoneDialog).toBeVisible();
await expect(
- phoneDialog.getByRole("heading", { name: "Use your Buzz identity" }),
+ phoneDialog.getByRole("heading", { name: "Scan to sign in" }),
).toBeVisible();
await expect(phoneDialog.getByTestId("identity-recovery-qr")).toBeVisible();
- await expect(page.getByTestId("nostr-import-card")).toBeVisible();
+ await expect(page.getByTestId("onboarding-content-card")).toBeVisible();
});
test("first-launch key import continues to machine setup", async ({ page }) => {
@@ -850,6 +850,31 @@ test("key import locks host navigation and ignores rapid duplicate submits", asy
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
});
+test("key import keeps alternate recovery methods disabled while submitting", async ({
+ page,
+}) => {
+ await installMockBridge(
+ page,
+ { identityImportDelayMs: 500 },
+ { skipCommunitySeed: true, skipOnboardingSeed: true },
+ );
+ await page.goto("/");
+
+ await page.getByRole("button", { name: "Use an existing key" }).click();
+ const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey));
+ await page.getByTestId("nostr-import-nsec-input").fill(importedNsec);
+ await page.getByTestId("nostr-import-submit").click();
+
+ await expect(page.getByTestId("nostr-import-file-button")).toBeDisabled();
+ await expect(page.getByTestId("nostr-import-phone-link")).toBeDisabled();
+ await page.getByTestId("nostr-import-file-button").click({ force: true });
+ await page.getByTestId("nostr-import-phone-link").click({ force: true });
+ await expect(page.getByTestId("nostr-import-nsec-input")).toBeVisible();
+ await expect(page.getByTestId("backup-recovery-dialog")).toHaveCount(0);
+ await expect(page.getByTestId("phone-recovery-dialog")).toHaveCount(0);
+ await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
+});
+
test("imported-key users can skip out of harness setup", async ({ page }) => {
// Regression: importing an existing key sets the onboarding state machine's
// "continuing" marker, which pinned the stage to onboarding even after
@@ -876,6 +901,138 @@ test("imported-key users can skip out of harness setup", async ({ page }) => {
await expect(page.getByTestId("onboarding-page-2")).toHaveCount(0);
});
+test("fresh-key harness completion continues directly into profile onboarding", async ({
+ page,
+}) => {
+ await page.addInitScript(() => {
+ const communityId = "e2e-existing-community";
+ window.localStorage.setItem(
+ "buzz-communities",
+ JSON.stringify([
+ {
+ id: communityId,
+ name: "E2E Test",
+ relayUrl: "ws://localhost:3000",
+ addedAt: new Date().toISOString(),
+ },
+ ]),
+ );
+ window.localStorage.setItem("buzz-active-community-id", communityId);
+ });
+ await installMockBridge(
+ page,
+ {
+ profileHasEvent: false,
+ deferProfileReads: true,
+ },
+ { skipCommunitySeed: true, skipOnboardingSeed: true },
+ );
+ await page.addInitScript(() => {
+ const testWindow = window as Window & {
+ __BUZZ_E2E__?: { bootSplashHoldMs?: number };
+ };
+ testWindow.__BUZZ_E2E__ = {
+ ...(testWindow.__BUZZ_E2E__ ?? {}),
+ bootSplashHoldMs: 2_000,
+ };
+ });
+ await page.goto("/");
+
+ await page.getByRole("button", { name: "Create a new identity key" }).click();
+ await page.getByRole("button", { name: "Create my private key" }).click();
+ await page.getByTestId("onboarding-next").click();
+ await expect(
+ page.getByRole("heading", { name: "Connect your AI provider" }),
+ ).toBeVisible();
+
+ await page.evaluate(() => {
+ const testWindow = window as Window & {
+ __BUZZ_E2E_ONBOARDING_LOADING_GATES__?: string[];
+ };
+ testWindow.__BUZZ_E2E_ONBOARDING_LOADING_GATES__ = [];
+ new MutationObserver(() => {
+ for (const testId of ["app-loading-gate", "boot-splash-overlay"]) {
+ if (document.querySelector(`[data-testid="${testId}"]`)) {
+ testWindow.__BUZZ_E2E_ONBOARDING_LOADING_GATES__?.push(testId);
+ }
+ }
+ }).observe(document.body, { childList: true, subtree: true });
+ });
+
+ await page.getByTestId("onboarding-setup-skip").click();
+
+ await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
+ await expect(
+ page.getByTestId("onboarding-step-dots").locator("span"),
+ ).toHaveCount(7);
+ await expect(
+ page.getByTestId("onboarding-step-dots").locator("span").nth(4),
+ ).toHaveClass(/w-7/);
+ const profileSubmit = page.getByTestId("onboarding-next");
+ await page.getByTestId("onboarding-display-name").fill("Delayed Profile");
+ await expect(profileSubmit).toBeDisabled();
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () =>
+ (
+ window as Window & {
+ __BUZZ_E2E_PROFILE_READS_PENDING__?: () => number;
+ }
+ ).__BUZZ_E2E_PROFILE_READS_PENDING__?.() ?? 0,
+ ),
+ )
+ .toBeGreaterThanOrEqual(1);
+ await page.getByTestId("onboarding-display-name").press("Enter");
+ await profileSubmit.evaluate((element) => {
+ const reactPropsKey = Object.keys(element).find((key) =>
+ key.startsWith("__reactProps$"),
+ );
+ if (!reactPropsKey) {
+ throw new Error("React props were not attached to the profile button");
+ }
+ const reactProps = (
+ element as unknown as Record<
+ string,
+ { onClick?: (event: MouseEvent) => void }
+ >
+ )[reactPropsKey];
+ reactProps.onClick?.(new MouseEvent("click"));
+ });
+ await expect(page.getByTestId("onboarding-display-name")).toBeEnabled();
+ expect(await commandCount(page, "update_profile")).toBe(0);
+ await expect(page.getByTestId("onboarding-page-avatar")).toHaveCount(0);
+
+ expect(
+ await page.evaluate(
+ () =>
+ (
+ window as Window & {
+ __BUZZ_E2E_RELEASE_PROFILE_READS__?: () => number;
+ }
+ ).__BUZZ_E2E_RELEASE_PROFILE_READS__?.() ?? 0,
+ ),
+ ).toBeGreaterThanOrEqual(1);
+ await expect(profileSubmit).toBeEnabled();
+ await profileSubmit.click();
+ await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
+ await page.getByTestId("onboarding-skip").click();
+ await expectWelcomeView(page);
+
+ await expect(page.getByTestId("app-loading-gate")).toHaveCount(0);
+ await expect(page.getByTestId("boot-splash-overlay")).toHaveCount(0);
+ expect(
+ await page.evaluate(
+ () =>
+ (
+ window as Window & {
+ __BUZZ_E2E_ONBOARDING_LOADING_GATES__?: string[];
+ }
+ ).__BUZZ_E2E_ONBOARDING_LOADING_GATES__ ?? [],
+ ),
+ ).toEqual([]);
+});
+
test("first-launch encrypted backup import asks for a passphrase and continues", async ({
page,
}) => {
@@ -969,7 +1126,7 @@ test("first-launch import accepts an .ncryptsec backup file", async ({
// Spec-vector blob the mock bridge accepts with the mock passphrase.
const mockNcryptsec =
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
- // File contents advance to the password stage inside the same dialog.
+ // File contents advance to the password stage inside the same sheet.
const backupDialog = page.getByTestId("backup-recovery-dialog");
const backupFileSection = backupDialog.getByTestId(
"nostr-import-backup-file-section",
@@ -1023,8 +1180,8 @@ test("first-launch import accepts an .ncryptsec backup file", async ({
backupDialog.getByTestId("nostr-import-passphrase"),
).toBeFocused();
- // Back first returns to backup-file selection instead of closing the dialog.
- await backupDialog.getByRole("button", { name: "Back", exact: true }).click();
+ // Back first returns to backup-file selection instead of leaving the sheet.
+ await page.getByRole("button", { name: "Back", exact: true }).click();
await expect(
backupDialog.getByRole("heading", { name: "Restore from a backup file" }),
).toBeVisible();
@@ -1040,7 +1197,7 @@ test("first-launch import accepts an .ncryptsec backup file", async ({
await backupDialog
.getByTestId("nostr-import-passphrase")
.fill("mock horse battery staple lake orbit");
- await backupDialog.getByTestId("nostr-import-submit").click();
+ await page.getByRole("button", { name: "Next" }).click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible();
@@ -1868,7 +2025,7 @@ test("first-community shows the scenario cards for localhost", async ({
await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
await expect(
page.getByRole("heading", {
- name: "Configure your default model settings",
+ name: "Choose your model settings",
}),
).toBeVisible();
await expect(page.getByTestId("global-agent-default-harness")).toHaveText(
@@ -2129,7 +2286,7 @@ test("canceling a join to an existing inactive community preserves it", async ({
.toEqual(["active-community", "existing-community"]);
});
-test("connected first-community profile keeps Back bottom-left and balances the avatar editor", async ({
+test("connected first-community profile keeps navigation inside the card and balances the avatar editor", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
@@ -2213,7 +2370,13 @@ test("connected first-community profile keeps Back bottom-left and balances the
if (!profileHeadingBox) {
throw new Error("Could not measure community profile heading position");
}
- expect(Math.abs(profileHeadingBox.y - 106)).toBeLessThan(8);
+ const onboardingCard = page.getByTestId("onboarding-content-card");
+ const onboardingCardBox = await onboardingCard.boundingBox();
+ if (!onboardingCardBox) {
+ throw new Error("Could not measure onboarding card position");
+ }
+ expect(profileHeadingBox.y).toBeGreaterThan(onboardingCardBox.y);
+ expect(profileHeadingBox.y).toBeLessThan(onboardingCardBox.y + 96);
const nameKey = page.getByTestId("community-profile-name-key");
const avatarButton = page.getByTestId("community-avatar-open");
await expect(nameKey).toBeVisible();
@@ -2221,26 +2384,20 @@ test("connected first-community profile keeps Back bottom-left and balances the
const nameKeyBox = await nameKey.boundingBox();
const avatarButtonBox = await avatarButton.boundingBox();
expect(nameKeyBox?.width).toBeGreaterThan(380);
- expect(avatarButtonBox?.width).toBe(144);
+ expect(avatarButtonBox?.width).toBeCloseTo(144, 3);
const nameKeyStyles = await nameKey.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
backgroundColor: styles.backgroundColor,
borderColor: styles.borderColor,
borderRadius: styles.borderRadius,
- boxShadow: styles.boxShadow,
fontSize: styles.fontSize,
};
});
- expect(nameKeyStyles.backgroundColor).toMatch(
- /^(rgba\(255, 255, 255, 0\.95\)|oklab\(.+ \/ 0\.95\))$/,
- );
- expect(nameKeyStyles.borderColor).toBe("rgba(113, 113, 6, 0.28)");
- expect(nameKeyStyles.boxShadow).toContain(
- "rgba(113, 113, 6, 0.5) 0px 0px 0px 1px inset",
- );
+ expect(nameKeyStyles.backgroundColor).toBe("rgb(249, 249, 249)");
+ expect(nameKeyStyles.borderColor).toBe("rgb(226, 226, 226)");
expect(nameKeyStyles).toMatchObject({
- borderRadius: "16px",
+ borderRadius: "12px",
fontSize: "14px",
});
await expect(page.getByText("Your username", { exact: true })).toBeVisible();
@@ -2540,7 +2697,7 @@ test("connected first-community profile keeps Back bottom-left and balances the
const backButton = page.getByTestId("community-profile-back");
await expect(nextButton).toHaveText("Next");
await expect(nextButton).toBeDisabled();
- await expect(backButton).toHaveText("Back");
+ await expect(backButton).toHaveAttribute("aria-label", "Back");
await expect(backButton).toBeEnabled();
const [nextBox, backBox] = await Promise.all([
nextButton.boundingBox(),
@@ -2549,12 +2706,11 @@ test("connected first-community profile keeps Back bottom-left and balances the
if (!nextBox || !backBox) {
throw new Error("Could not measure community profile navigation controls");
}
- const viewport = page.viewportSize();
- if (!viewport) throw new Error("Could not measure onboarding viewport");
- expect(backBox.x).toBeLessThanOrEqual(32);
- expect(
- Math.abs(nextBox.x + nextBox.width / 2 - viewport.width / 2),
- ).toBeLessThanOrEqual(1);
+ expect(backBox.x).toBeGreaterThanOrEqual(onboardingCardBox.x + 40);
+ expect(backBox.width).toBe(52);
+ expect(nextBox.x + nextBox.width).toBeLessThanOrEqual(
+ onboardingCardBox.x + onboardingCardBox.width - 40,
+ );
await backButton.click();
await expect(
@@ -2579,6 +2735,9 @@ test("name-only community profile save preserves an existing avatar", async ({
skipOnboardingSeed: true,
});
await page.goto("/");
+ await expect
+ .poll(() => commandCount(page, "get_profile"))
+ .toBeGreaterThanOrEqual(2);
const existingAvatarUrl =
"https://mock.relay/media/existing-community-avatar.png";
@@ -3205,7 +3364,7 @@ test("avatar step reveals preset backgrounds after the first emoji pick", async
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
- await page.getByRole("tab", { name: "Emoji" }).click();
+ await page.getByTestId("onboarding-avatar-mode-emoji").click();
const colorGridShell = page.getByTestId("onboarding-avatar-color-grid-shell");
await expect(colorGridShell).toHaveAttribute("aria-hidden", "true");
@@ -4057,6 +4216,7 @@ test("same-relay identity replacement rebuilds the community boundary (A→B→A
test("onboarding relay reconnect — click shows Connected then auto-dismisses", async ({
page,
}) => {
+ await page.setViewportSize({ width: 800, height: 500 });
// Produce the relay reconnect card via a relay-unreachable profile save error.
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
@@ -4096,6 +4256,30 @@ test("onboarding relay reconnect — click shows Connected then auto-dismisses",
await expect(card).toBeHidden({ timeout: 10_000 });
});
+test("onboarding relay reconnect — dismiss is clickable at minimum size", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 800, height: 500 });
+ await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
+ await installMockBridge(
+ page,
+ {
+ profileUpdateError: "relay unreachable: could not connect to relay",
+ },
+ { skipOnboardingSeed: true },
+ );
+ await page.goto("/");
+
+ await page.getByTestId("onboarding-display-name").fill("Morty QA");
+ await page.getByTestId("onboarding-next").click();
+ const card = page.getByTestId("onboarding-relay-reconnect-card");
+ await expect(card).toBeVisible();
+ await page
+ .getByRole("button", { name: "Dismiss relay notification" })
+ .click();
+ await expect(card).toHaveCount(0);
+});
+
test("onboarding relay reconnect — connected without a prior click does not show Connected", async ({
page,
}) => {
diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts
index c3f4ed69f4c..87fe86b5b23 100644
--- a/desktop/tests/helpers/bridge.ts
+++ b/desktop/tests/helpers/bridge.ts
@@ -329,6 +329,8 @@ type MockBridgeOptions = {
/** Delay (ms) for newest-page fetches; see e2eBridge mock config. */
channelHeadDelayMs?: number;
profileReadDelayMs?: number;
+ /** Hold `get_profile` responses until `__BUZZ_E2E_RELEASE_PROFILE_READS__()`. */
+ deferProfileReads?: boolean;
profileReadError?: string;
/** Override whether get_profile reports a real kind:0 event. */
profileHasEvent?: boolean;
@@ -473,6 +475,8 @@ type MockBridgeOptions = {
* can exercise the "Thread deleted" label / disabled-send path.
*/
deletedEventIds?: string[];
+ /** Reject one identity read after the configured number of successful reads. */
+ identityReadErrorAfter?: { message: string; successfulReads: number };
/**
* When true, `get_identity` returns `lost: true` until `persist_current_identity`
* or `import_identity` is invoked. Drives the identity-lost recovery UX in tests.