feat(desktop): add key backup and agent defaults to onboarding, make avatar optional (#1767)

This commit is contained in:
Will Pfleger
2026-07-12 18:46:02 -04:00
committed by GitHub
parent f3319dd111
commit d2e87e1ca7
24 changed files with 2346 additions and 356 deletions

View File

@@ -92,8 +92,12 @@ export default defineConfig({
"**/identity-lost.spec.ts",
"**/global-agent-config-screenshots.spec.ts",
"**/doctor-states.spec.ts",
"**/agent-lifecycle-feedback.spec.ts",
"**/onboarding-avatar-skip.spec.ts",
"**/onboarding-backup.spec.ts",
"**/onboarding-agent-defaults.spec.ts",
"**/profile-nsec-reveal.spec.ts",
"**/agent-provider-dropdowns.spec.ts",
"**/agent-lifecycle-feedback.spec.ts",
],
use: {
...devices["Desktop Chrome"],

View File

@@ -23,8 +23,8 @@ const rules = [
// glyphs are fixed display sizes sized to their avatar box (not readable
// message text), so they are exempted from the readable-text px rule.
const overrides = new Set([
"src/features/settings/ui/ProfileSettingsCard.tsx:584",
"src/features/onboarding/ui/AvatarStep.tsx:89",
"src/features/settings/ui/ProfileSettingsCard.tsx:672",
"src/features/onboarding/ui/AvatarStep.tsx:95",
"src/features/agents/ui/AgentCreationPreview.tsx:666",
"src/features/agents/ui/AgentCreationPreview.tsx:747",
]);

View File

@@ -0,0 +1,288 @@
/**
* Controlled field group for global agent config (provider, model, effort, env vars).
*
* Used by GlobalAgentConfigSettingsCard (settings panel) and AgentDefaultsSection
* (onboarding setup step). The parent manages load/save state; this component is
* purely presentational and calls onConfigChange on every user edit.
*/
import * as React from "react";
import type { BakedEnvEntry } from "@/shared/api/tauri";
import type {
AcpRuntimeCatalogEntry,
GlobalAgentConfig,
} from "@/shared/api/types";
import { EnvVarsEditor } from "@/features/agents/ui/EnvVarsEditor";
import type { InheritedEnvRow } from "@/features/agents/ui/EnvVarsEditor";
import { getBakedProviderInheritLabel } from "@/features/agents/ui/bakedEnvHelpers";
import {
AUTO_PROVIDER_DROPDOWN_VALUE,
BLOCK_BUILD_HIDDEN_PROVIDER_IDS,
CUSTOM_PROVIDER_DROPDOWN_VALUE,
getPersonaProviderOptions,
} from "@/features/agents/ui/personaDialogPickers";
import { AgentModelField } from "@/features/agents/ui/personaProviderModelFields";
import { usePersonaModelDiscovery } from "@/features/agents/ui/usePersonaModelDiscovery";
import {
BUZZ_AGENT_THINKING_EFFORT,
getProviderEffortConfig,
} from "@/features/agents/ui/buzzAgentConfig";
import {
EffortSelectField,
useEffortAutoClear,
} from "@/features/agents/ui/buzzAgentModelTuningFields";
import { Input } from "@/shared/ui/input";
import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup";
/** Sentinel value for an unconfigured global agent config. */
export const EMPTY_GLOBAL_CONFIG: GlobalAgentConfig = {
env_vars: {},
provider: null,
model: null,
};
/** Baked env keys that route to structured controls, not the generic env editor. */
const BAKED_STRUCTURED_KEYS = new Set([
"BUZZ_AGENT_PROVIDER",
"BUZZ_AGENT_MODEL",
BUZZ_AGENT_THINKING_EFFORT,
]);
export type GlobalAgentConfigFieldsProps = {
bakedEnv: BakedEnvEntry[];
buzzAgentRuntime: AcpRuntimeCatalogEntry | undefined;
config: GlobalAgentConfig;
isCustomModelEditing: boolean;
isCustomProvider: boolean;
onConfigChange: (next: GlobalAgentConfig) => void;
onCustomModelEditingChange: (value: boolean) => void;
onIsCustomProviderChange: (value: boolean) => void;
};
export function GlobalAgentConfigFields({
bakedEnv,
buzzAgentRuntime,
config,
isCustomModelEditing,
isCustomProvider,
onConfigChange,
onCustomModelEditingChange,
onIsCustomProviderChange,
}: GlobalAgentConfigFieldsProps) {
const bakedProvider = React.useMemo(
() => bakedEnv.find((e) => e.key === "BUZZ_AGENT_PROVIDER")?.value ?? null,
[bakedEnv],
);
const bakedModel = React.useMemo(
() => bakedEnv.find((e) => e.key === "BUZZ_AGENT_MODEL")?.value ?? null,
[bakedEnv],
);
const bakedEffort = React.useMemo(
() =>
bakedEnv.find((e) => e.key === BUZZ_AGENT_THINKING_EFFORT)?.value ?? null,
[bakedEnv],
);
const bakedGenericRows = React.useMemo<readonly InheritedEnvRow[]>(
() => bakedEnv.filter((e) => !BAKED_STRUCTURED_KEYS.has(e.key)),
[bakedEnv],
);
const providerValue = config.provider ?? "";
const providerForDiscovery = isCustomProvider ? "" : providerValue;
const {
discoveredModelOptions,
modelDiscoveryLoading,
modelDiscoveryStatus,
} = usePersonaModelDiscovery({
envVars: config.env_vars,
isCustomProviderEditing: isCustomProvider,
modelFieldVisible: true,
open: true,
provider: providerForDiscovery,
selectedRuntime: buzzAgentRuntime,
});
const currentEffortForAutoClear =
config.env_vars[BUZZ_AGENT_THINKING_EFFORT] ?? "";
const { validValues: effortValidForAutoClear } = getProviderEffortConfig(
config.provider ?? "",
config.model ?? "",
);
useEffortAutoClear({
currentEffort: currentEffortForAutoClear,
effortValid: effortValidForAutoClear,
onClear: () => {
const nextEnvVars = { ...config.env_vars };
delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT];
onConfigChange({ ...config, env_vars: nextEnvVars });
},
});
function handleProviderChange(value: string) {
if (value === CUSTOM_PROVIDER_DROPDOWN_VALUE) {
onIsCustomProviderChange(true);
return;
}
if (value === AUTO_PROVIDER_DROPDOWN_VALUE || value === "") {
onIsCustomProviderChange(false);
onConfigChange({ ...config, provider: null });
} else {
onIsCustomProviderChange(false);
onConfigChange({ ...config, provider: value });
}
}
function handleCustomProviderInput(value: string) {
onConfigChange({ ...config, provider: value || null });
}
function handleModelChange(value: string) {
onConfigChange({ ...config, model: value || null });
}
function handleEnvVarsChange(next: Record<string, string>) {
const effort = config.env_vars[BUZZ_AGENT_THINKING_EFFORT];
const merged =
effort !== undefined
? { ...next, [BUZZ_AGENT_THINKING_EFFORT]: effort }
: next;
onConfigChange({ ...config, env_vars: merged });
}
const bakedEnvKeys = React.useMemo(
() => bakedEnv.map((e) => e.key),
[bakedEnv],
);
// On internal Block builds, BUZZ_AGENT_PROVIDER is baked in and a boot
// migration rewrites v1→v2. Hide the legacy v1 option so it is not offered
// for new selections; OSS builds show it.
const hideProviderIds = React.useMemo(
() =>
bakedEnvKeys.includes("BUZZ_AGENT_PROVIDER")
? BLOCK_BUILD_HIDDEN_PROVIDER_IDS
: new Set<string>(),
[bakedEnvKeys],
);
const providerOptions = getPersonaProviderOptions(
providerValue,
"buzz-agent",
undefined,
hideProviderIds,
);
const providerSelectValue = isCustomProvider
? CUSTOM_PROVIDER_DROPDOWN_VALUE
: providerValue || AUTO_PROVIDER_DROPDOWN_VALUE;
const providerZeroLabel = React.useMemo(() => {
if (!bakedProvider) return null;
return getBakedProviderInheritLabel(bakedProvider, providerOptions);
}, [bakedProvider, providerOptions]);
const { validValues: effortValid, defaultValue: effortDefault } =
getProviderEffortConfig(config.provider ?? "", config.model ?? "");
const currentEffort = config.env_vars[BUZZ_AGENT_THINKING_EFFORT] ?? "";
return (
<SettingsOptionGroup>
{/* Provider field */}
<div className="space-y-1.5 p-3">
<label className="text-sm font-medium" htmlFor="global-agent-provider">
Default LLM provider
</label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs"
id="global-agent-provider"
onChange={(e) => handleProviderChange(e.target.value)}
value={providerSelectValue}
>
{providerOptions.map((opt) => (
<option key={opt.id} value={opt.id || AUTO_PROVIDER_DROPDOWN_VALUE}>
{opt.id === "" ? (providerZeroLabel ?? opt.label) : opt.label}
</option>
))}
<option value={CUSTOM_PROVIDER_DROPDOWN_VALUE}>
Custom provider…
</option>
</select>
{isCustomProvider ? (
<Input
aria-label="Custom global provider ID"
autoCorrect="off"
onChange={(e) => handleCustomProviderInput(e.target.value)}
placeholder="Custom provider ID"
value={providerValue}
/>
) : null}
<p className="text-xs text-muted-foreground">
Applies to all agents that don't have a per-agent provider set.
</p>
</div>
{/* Model field */}
<div className="space-y-1.5 p-3">
<AgentModelField
disabled={false}
discoveredModelOptions={discoveredModelOptions}
globalModel={bakedModel ?? undefined}
id="global-agent-model"
isCustomModelEditing={isCustomModelEditing}
isRequired={false}
model={config.model ?? ""}
modelDiscoveryLoading={modelDiscoveryLoading}
modelDiscoveryStatus={modelDiscoveryStatus}
onIsCustomModelEditingChange={onCustomModelEditingChange}
onModelChange={handleModelChange}
/>
<p className="text-xs text-muted-foreground">
Applies to all agents that don't have a per-agent model set.
</p>
</div>
{/* Thinking / Effort */}
<div className="p-3">
<EffortSelectField
currentEffort={currentEffort}
effortDefault={effortDefault}
effortValid={effortValid}
htmlFor="global-agent-thinking-effort"
inheritFallbackLabel={
effortDefault !== null ? `Default (${effortDefault})` : undefined
}
inheritedEffort={bakedEffort ?? undefined}
label="Default thinking / effort"
onChange={(value) => {
const nextEnvVars = { ...config.env_vars };
if (value === "") {
delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT];
} else {
nextEnvVars[BUZZ_AGENT_THINKING_EFFORT] = value;
}
onConfigChange({ ...config, env_vars: nextEnvVars });
}}
testId="global-agent-thinking-effort-select"
/>
<p className="mt-1.5 text-xs text-muted-foreground">
Default thinking/reasoning effort applied to all agents. Per-agent
settings override this.
</p>
</div>
{/* Env vars */}
<div className="p-3">
<EnvVarsEditor
helperText="Injected into all agents as the lowest-priority layer. Per-agent values override these."
inheritedRows={bakedGenericRows}
inheritedRowsLabel="build"
label="Global environment variables"
onChange={handleEnvVarsChange}
value={Object.fromEntries(
Object.entries(config.env_vars).filter(
([k]) => k !== BUZZ_AGENT_THINKING_EFFORT,
),
)}
/>
</div>
</SettingsOptionGroup>
);
}

View File

@@ -26,11 +26,17 @@ type AvatarStepProps = {
submit: ProfileStepActions["submit"];
updateAvatarUrl: ProfileStepActions["updateAvatarUrl"];
};
/** Step number for the compact progress bar (mobile layout). */
currentStep: number;
direction: OnboardingTransitionDirection;
/** When true, a ghost "Skip for now" button is always visible (not just on error). */
showAlwaysSkip?: boolean;
state: Pick<
ProfileStepState,
"avatar" | "isSaving" | "isUploadingAvatar" | "name" | "saveRecovery"
>;
/** Total steps for the compact progress bar (mobile layout). */
totalSteps?: number;
};
function ErrorBanner({ message }: { message: string | null }) {
@@ -119,6 +125,7 @@ function AvatarPreview({
function AvatarStepActions({
canSubmit,
currentStep,
hidden,
isSaving,
isUploadingAvatar,
@@ -127,8 +134,11 @@ function AvatarStepActions({
onSkipForNow,
onSubmit,
saveRecovery,
showAlwaysSkip,
totalSteps,
}: {
canSubmit: boolean;
currentStep: number;
hidden: boolean;
isSaving: boolean;
isUploadingAvatar: boolean;
@@ -137,6 +147,8 @@ function AvatarStepActions({
onSkipForNow: () => void;
onSubmit: () => void;
saveRecovery: ProfileStepState["saveRecovery"];
showAlwaysSkip: boolean;
totalSteps?: number;
}) {
const areNavigationActionsDisabled = isSaving || isUploadingAvatar;
@@ -177,6 +189,8 @@ function AvatarStepActions({
</Button>
{saveRecovery.canSkipForNow ? (
// Error-recovery path: exits onboarding entirely when there is no
// saved display name to fall back on.
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground max-lg:pointer-events-auto"
data-testid="onboarding-skip"
@@ -187,6 +201,18 @@ function AvatarStepActions({
>
Skip for now
</Button>
) : showAlwaysSkip && !saveRecovery.errorMessage ? (
// Normal path: advances to the theme step without saving an avatar.
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground max-lg:pointer-events-auto"
data-testid="onboarding-skip"
disabled={areNavigationActionsDisabled}
onClick={onContinueWithoutSaving}
type="button"
variant="ghost"
>
Skip for now
</Button>
) : null}
{saveRecovery.canAdvanceWithoutSaving ? (
@@ -217,8 +243,9 @@ function AvatarStepActions({
activeSegmentClassName="bg-primary"
className="mt-1 max-lg:pointer-events-auto lg:hidden"
completeSegmentClassName="bg-primary/35"
currentStep={3}
currentStep={currentStep}
inactiveSegmentClassName="bg-muted-foreground/25"
totalSteps={totalSteps}
/>
</motion.div>
)}
@@ -226,7 +253,14 @@ function AvatarStepActions({
);
}
export function AvatarStep({ actions, direction, state }: AvatarStepProps) {
export function AvatarStep({
actions,
currentStep,
direction,
showAlwaysSkip = false,
state,
totalSteps,
}: AvatarStepProps) {
const {
advanceWithoutSaving,
back,
@@ -377,6 +411,7 @@ export function AvatarStep({ actions, direction, state }: AvatarStepProps) {
<AvatarStepActions
canSubmit={canSubmit}
currentStep={currentStep}
hidden={areActionsHidden}
isSaving={isSaving}
isUploadingAvatar={isUploadingAvatar}
@@ -385,6 +420,8 @@ export function AvatarStep({ actions, direction, state }: AvatarStepProps) {
onSkipForNow={skipForNow}
onSubmit={submit}
saveRecovery={saveRecovery}
showAlwaysSkip={showAlwaysSkip}
totalSteps={totalSteps}
/>
</motion.div>
</motion.div>

View File

@@ -0,0 +1,222 @@
import { AlertTriangle, RefreshCw } from "lucide-react";
import * as React from "react";
import { getNsec } from "@/shared/api/tauriIdentity";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
import { StepProgress } from "@/shared/ui/step-progress";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
} from "./OnboardingSlideTransition";
import { NsecMaskedDisplay } from "./NsecMaskedDisplay";
/**
* Pure helper so the disabled logic can be unit-tested without a DOM.
*
* Disabled when:
* - still loading (key not fetched yet)
* - load failed (only the explicit "Skip for now" ghost advances past an error)
* - key loaded AND checkbox not yet checked
*
* Enabled when key is null after a clean (non-error) load — backend returned
* nothing, so there is nothing to acknowledge and the user may proceed.
*/
export function backupNextDisabled({
isLoading,
loadError,
nsec,
hasAcknowledged,
}: {
isLoading: boolean;
loadError: string | null;
nsec: string | null;
hasAcknowledged: boolean;
}): boolean {
return isLoading || loadError !== null || (nsec !== null && !hasAcknowledged);
}
type BackupStepProps = {
currentStep: number;
direction: OnboardingTransitionDirection;
totalSteps: number;
onBack: () => void;
onNext: () => void;
};
/**
* Onboarding backup step — shows the user their nsec and requires
* acknowledgement before advancing. Only shown on the fresh-key path.
*/
export function BackupStep({
currentStep,
direction,
totalSteps,
onBack,
onNext,
}: BackupStepProps) {
const [nsec, setNsec] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState<string | null>(null);
const [hasAcknowledged, setHasAcknowledged] = React.useState(false);
const cancelledRef = React.useRef(false);
const loadNsec = React.useCallback(async () => {
setIsLoading(true);
setLoadError(null);
try {
const value = await getNsec();
if (!cancelledRef.current) setNsec(value);
} catch (err) {
if (!cancelledRef.current)
setLoadError(
err instanceof Error
? err.message
: "Failed to retrieve private key.",
);
} finally {
if (!cancelledRef.current) setIsLoading(false);
}
}, []);
React.useEffect(() => {
cancelledRef.current = false;
void loadNsec();
return () => {
// Back-during-fetch: cancel any in-flight setState calls and clear the
// nsec from memory on unmount (backup step is only on the fresh-key path).
cancelledRef.current = true;
setNsec(null);
};
}, [loadNsec]);
return (
<OnboardingSlideTransition
className="flex w-full flex-col items-center pb-36 lg:pb-0"
data-testid="onboarding-page-backup"
direction={direction}
transitionKey={`backup-${direction}`}
>
<div className="w-full max-w-[500px] text-center">
<h1 className="text-3xl font-semibold tracking-tight text-foreground">
Save your private key
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Buzz generated a Nostr private key for you. This key is stored in your
system keychain, but you should also save it somewhere safe in case
you ever need to restore your account on another device.
</p>
</div>
<div className="mt-8 w-full max-w-[500px] space-y-4 text-left">
{isLoading ? (
<div className="flex items-center gap-2 rounded-lg border border-border/70 bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
<Spinner className="h-4 w-4 border-2" />
Loading your private key…
</div>
) : loadError ? (
<div className="space-y-3">
<div
className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"
data-testid="backup-load-error"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
Could not retrieve your private key: {loadError}. You can
continue and find it later in Settings &gt; Profile &gt;
Identity.
</span>
</div>
<Button
className="h-8 gap-1.5 text-sm"
data-testid="backup-retry"
onClick={() => void loadNsec()}
size="sm"
type="button"
variant="outline"
>
<RefreshCw className="h-3.5 w-3.5" />
Try again
</Button>
</div>
) : nsec ? (
<NsecMaskedDisplay nsec={nsec} />
) : (
<p className="text-sm text-muted-foreground">
No key available to back up.
</p>
)}
{nsec ? (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm leading-5 text-amber-700 dark:text-amber-400">
<strong>Never share your private key.</strong> Anyone with it can
impersonate you and access everything in your account.
</div>
) : null}
{!isLoading && !loadError && nsec ? (
<label className="flex cursor-pointer items-start gap-3">
<input
checked={hasAcknowledged}
className="mt-0.5 h-4 w-4 shrink-0 cursor-pointer accent-primary"
data-testid="backup-acknowledge"
onChange={(e) => setHasAcknowledged(e.target.checked)}
type="checkbox"
/>
<span className="text-sm leading-5">
I've saved my private key in a safe place
</span>
</label>
) : null}
</div>
<div className="mt-8 flex w-full max-w-[500px] flex-col gap-3 max-lg:pointer-events-none max-lg:fixed max-lg:inset-x-0 max-lg:bottom-0 max-lg:z-40 max-lg:mt-0 max-lg:max-w-none max-lg:border-t max-lg:border-border max-lg:bg-background max-lg:p-4 max-lg:pb-[max(1rem,env(safe-area-inset-bottom))]">
<Button
className="h-10 w-full max-lg:pointer-events-auto"
data-testid="onboarding-next"
disabled={backupNextDisabled({
isLoading,
loadError,
nsec,
hasAcknowledged,
})}
onClick={onNext}
type="button"
>
Next
</Button>
{loadError ? (
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground max-lg:pointer-events-auto"
data-testid="backup-skip"
onClick={onNext}
type="button"
variant="ghost"
>
Skip for now
</Button>
) : null}
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground max-lg:pointer-events-auto"
data-testid="onboarding-back"
onClick={onBack}
type="button"
variant="ghost"
>
Back
</Button>
<StepProgress
activeSegmentClassName="bg-primary"
className="mt-1 max-lg:pointer-events-auto lg:hidden"
completeSegmentClassName="bg-primary/35"
currentStep={currentStep}
inactiveSegmentClassName="bg-muted-foreground/25"
totalSteps={totalSteps}
/>
</div>
</OnboardingSlideTransition>
);
}

View File

@@ -0,0 +1,91 @@
import { Check, Copy, Eye, EyeOff } from "lucide-react";
import * as React from "react";
import { Button } from "@/shared/ui/button";
type NsecMaskedDisplayProps = {
nsec: string;
};
/**
* Masked nsec display with reveal toggle and copy button.
*
* Security invariants:
* - nsec is not present in the DOM until the user clicks Reveal
* - select is disabled while masked (user-select: none)
* - state is cleared when the component unmounts
*/
export function NsecMaskedDisplay({ nsec }: NsecMaskedDisplayProps) {
const [isRevealed, setIsRevealed] = React.useState(false);
const [isCopied, setIsCopied] = React.useState(false);
const copyTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(() => {
return () => {
setIsRevealed(false);
if (copyTimerRef.current) {
clearTimeout(copyTimerRef.current);
}
};
}, []);
function handleRevealToggle() {
setIsRevealed((prev) => !prev);
}
async function handleCopy() {
await navigator.clipboard.writeText(nsec);
setIsCopied(true);
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
copyTimerRef.current = setTimeout(() => setIsCopied(false), 2000);
}
return (
<div className="overflow-hidden rounded-lg border border-border/70 bg-muted/30">
<div className="flex items-center gap-2 px-3 py-2">
<p
className={`min-w-0 flex-1 break-all font-mono text-xs leading-5 ${
isRevealed
? "select-text text-foreground"
: "select-none text-muted-foreground blur-[4px]"
}`}
data-testid="nsec-value"
>
{isRevealed ? nsec : nsec.slice(0, 6) + "•".repeat(52)}
</p>
<div className="flex shrink-0 gap-1">
<Button
aria-label={isRevealed ? "Hide private key" : "Reveal private key"}
className="h-7 w-7 text-muted-foreground hover:text-foreground"
data-testid="nsec-reveal-toggle"
onClick={handleRevealToggle}
size="icon"
type="button"
variant="ghost"
>
{isRevealed ? (
<EyeOff className="h-4 w-4" aria-hidden="true" />
) : (
<Eye className="h-4 w-4" aria-hidden="true" />
)}
</Button>
<Button
aria-label="Copy private key"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
data-testid="nsec-copy"
disabled={!isRevealed}
onClick={() => void handleCopy()}
size="icon"
type="button"
variant="ghost"
>
{isCopied ? (
<Check className="h-4 w-4 text-primary" aria-hidden="true" />
) : (
<Copy className="h-4 w-4" aria-hidden="true" />
)}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -23,6 +23,7 @@ import { ONBOARDING_DEFAULT_THEME_NAME } from "@/shared/theme/theme-loader";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { StepProgress } from "@/shared/ui/step-progress";
import { AvatarStep } from "./AvatarStep";
import { BackupStep } from "./BackupStep";
import { MembershipDenied } from "./MembershipDenied";
import { NostrKeyImportForm } from "./NostrKeyImportForm";
import {
@@ -169,6 +170,9 @@ export function OnboardingFlow({
const [isUploadingAvatar, setIsUploadingAvatar] = React.useState(false);
const [isProfileAdvancePending, setIsProfileAdvancePending] =
React.useState(false);
// Track whether the user imported an existing key. The fresh-key path shows
// the backup step; the imported-key path skips it (user already has a backup).
const [identityWasImported, setIdentityWasImported] = React.useState(false);
const [membershipRetryPage, setMembershipRetryPage] =
React.useState<OnboardingPage>("avatar");
const [transitionDirection, setTransitionDirection] =
@@ -192,7 +196,11 @@ export function OnboardingFlow({
}, [accentColor, setAccentColor, setTheme, themeName]);
React.useEffect(() => {
if (currentPage === "profile" || currentPage === "avatar") {
if (
currentPage === "profile" ||
currentPage === "backup" ||
currentPage === "avatar"
) {
void preloadThemePreviewVars().catch(() => undefined);
}
@@ -248,6 +256,11 @@ export function OnboardingFlow({
setCurrentPage("key-import");
}, []);
const showBackupPage = React.useCallback(() => {
setTransitionDirection("forward");
setCurrentPage("backup");
}, []);
const saveProfileAndContinue = React.useCallback(
async (nextPage: OnboardingPage) => {
if (isProfileAdvancePending) {
@@ -303,17 +316,13 @@ export function OnboardingFlow({
}
}
if (nextPage === "avatar") {
showAvatarPage();
return;
}
if (nextPage === "theme") {
showThemePage();
return;
}
showSetupPage();
const showNextPage: Partial<Record<OnboardingPage, () => void>> = {
backup: showBackupPage,
avatar: () => showAvatarPage(),
theme: showThemePage,
setup: showSetupPage,
};
(showNextPage[nextPage] ?? showSetupPage)();
} finally {
setIsProfileAdvancePending(false);
}
@@ -324,6 +333,7 @@ export function OnboardingFlow({
profileUpdateMutation,
savedProfile,
showAvatarPage,
showBackupPage,
showSetupPage,
showThemePage,
],
@@ -384,14 +394,31 @@ export function OnboardingFlow({
}
: profileStepState.saveRecovery,
};
const currentStep =
currentPage === "profile" || currentPage === "key-import"
? 2
: currentPage === "avatar"
? 3
: currentPage === "theme"
? 4
: 5;
// Page sequences by path — step 1 is the workspace-picker that precedes
// onboarding, so these pages start at step 2 (STEP_OFFSET below).
// Fresh-key path: profile(2) → backup(3) → avatar(4) → theme(5) → setup(6)
// Imported-key path: profile(2) → avatar(3) → theme(4) → setup(5)
const FRESH_STEPS: OnboardingPage[] = [
"profile",
"backup",
"avatar",
"theme",
"setup",
];
const IMPORT_STEPS: OnboardingPage[] = [
"profile",
"avatar",
"theme",
"setup",
];
const STEP_OFFSET = 2;
const activeSteps = identityWasImported ? IMPORT_STEPS : FRESH_STEPS;
// 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 + 1; // +1 for step 1 (workspace picker)
const hideFixedProgressOnCompact =
currentPage === "avatar" || currentPage === "theme";
@@ -407,6 +434,7 @@ export function OnboardingFlow({
queryClient.removeQueries({ queryKey: profileQueryKey });
profileUpdateMutation.reset();
setDeniedPubkey("");
setIdentityWasImported(true);
setTransitionDirection("backward");
setCurrentPage("profile");
},
@@ -460,6 +488,7 @@ export function OnboardingFlow({
<div
className={`buzz-startup-shell flex items-center justify-center bg-background px-4 py-8 text-foreground ${
currentPage === "profile" ||
currentPage === "backup" ||
currentPage === "avatar" ||
currentPage === "key-import"
? "buzz-onboarding-neutral-theme"
@@ -495,6 +524,7 @@ export function OnboardingFlow({
completeSegmentClassName="bg-primary/35"
currentStep={currentStep}
inactiveSegmentClassName="bg-muted-foreground/25"
totalSteps={totalOnboardingSteps}
/>
</OnboardingSlideTransition>
@@ -513,7 +543,9 @@ export function OnboardingFlow({
onUploadingChange: setIsUploadingAvatar,
skipForNow,
submit: () => {
void saveProfileAndContinue("avatar");
void saveProfileAndContinue(
identityWasImported ? "avatar" : "backup",
);
},
updateAvatarUrl: updateAvatarUrlDraft,
updateDisplayName: updateDisplayNameDraft,
@@ -566,11 +598,19 @@ export function OnboardingFlow({
onImport={importExistingKey}
/>
</OnboardingSlideTransition>
) : currentPage === "backup" ? (
<BackupStep
currentStep={currentStep}
direction={transitionDirection}
onBack={showProfilePage}
onNext={() => showAvatarPage()}
totalSteps={totalOnboardingSteps}
/>
) : currentPage === "avatar" ? (
<AvatarStep
actions={{
advanceWithoutSaving: () => showThemePage(),
back: showProfilePage,
back: identityWasImported ? showProfilePage : showBackupPage,
onUploadingChange: setIsUploadingAvatar,
skipForNow,
submit: () => {
@@ -578,8 +618,11 @@ export function OnboardingFlow({
},
updateAvatarUrl: updateAvatarUrlDraft,
}}
currentStep={currentStep}
direction={transitionDirection}
showAlwaysSkip={true}
state={avatarStepState}
totalSteps={totalOnboardingSteps}
/>
) : currentPage === "theme" ? (
<ThemeStep

View File

@@ -5,6 +5,7 @@ import {
Check,
ExternalLink,
Plus,
RefreshCw,
TerminalSquare,
} from "lucide-react";
@@ -13,7 +14,20 @@ import {
useInstallAcpRuntimeMutation,
} from "@/features/agents/hooks";
import { describeResolvedCommand } from "@/features/agents/ui/agentUi";
import type { AcpRuntimeCatalogEntry } from "@/shared/api/types";
import {
GlobalAgentConfigFields,
EMPTY_GLOBAL_CONFIG,
} from "@/features/agents/ui/GlobalAgentConfigFields";
import { createSaveCoalescer } from "./saveCoalescer";
import { getBakedBuildEnv, type BakedEnvEntry } from "@/shared/api/tauri";
import {
getGlobalAgentConfig,
setGlobalAgentConfig,
} from "@/shared/api/tauriGlobalAgentConfig";
import type {
AcpRuntimeCatalogEntry,
GlobalAgentConfig,
} from "@/shared/api/types";
import { getInstallErrorMessage } from "@/shared/lib/installError";
import { cn } from "@/shared/lib/cn";
import { useTheme } from "@/shared/theme/ThemeProvider";
@@ -25,6 +39,7 @@ import {
OnboardingSlideTransition,
} from "./OnboardingSlideTransition";
import type { SetupStepActions, SetupStepState } from "./types";
import { resolveAgentReadiness } from "./agentReadiness";
type SetupStepProps = {
actions: SetupStepActions;
@@ -42,6 +57,150 @@ type InstallResultState = {
success: boolean;
};
function AgentDefaultsSection() {
const runtimesQuery = useAcpRuntimesQuery();
const [config, setConfig] =
React.useState<GlobalAgentConfig>(EMPTY_GLOBAL_CONFIG);
const [isLoading, setIsLoading] = React.useState(true);
const [isCustomProvider, setIsCustomProvider] = React.useState(false);
const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false);
const [bakedEnv, setBakedEnv] = React.useState<BakedEnvEntry[]>([]);
const coalescerRef = React.useRef<{
enqueue: (value: GlobalAgentConfig) => void;
cancel: () => void;
} | null>(null);
React.useEffect(() => {
let unmounted = false;
getGlobalAgentConfig()
.then((loaded) => {
if (!unmounted) {
setConfig(loaded);
setIsLoading(false);
}
})
.catch(() => {
if (!unmounted) setIsLoading(false);
});
getBakedBuildEnv()
.then((env) => {
if (!unmounted) setBakedEnv(env);
})
.catch(() => undefined);
// The coalescer serializes autosaves and drains any edit that arrived
// while a previous save was in flight. Cancel on unmount so a slow
// in-flight request never calls setState on an unmounted component.
const coalescer = createSaveCoalescer<GlobalAgentConfig>(
// set_global_agent_config returns a save result (config + restart
// counts); the coalescer round-trips the persisted config only.
async (next) => (await setGlobalAgentConfig(next)).config,
() => undefined, // saving state not surfaced in this autosave UX
(saved) => {
if (!unmounted) setConfig(saved);
},
);
coalescerRef.current = coalescer;
return () => {
unmounted = true;
coalescer.cancel();
};
}, []);
const buzzAgentRuntime = React.useMemo(
() => (runtimesQuery.data ?? []).find((r) => r.id === "buzz-agent"),
[runtimesQuery.data],
);
const readiness = resolveAgentReadiness(runtimesQuery.data ?? [], config);
return (
<section className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<h2 className="text-xl font-semibold tracking-tight text-foreground">
Agent defaults
</h2>
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">
Configure the LLM provider and credentials that buzz-agent uses, or
connect a CLI harness like Claude or Goose above.
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{readiness.ready ? (
<Badge
className="border border-primary/20 bg-primary/10 text-primary"
data-testid="agent-readiness-badge"
variant="outline"
>
{readiness.reason === "cli"
? `${readiness.runtimeLabel} ready`
: "buzz-agent configured"}
</Badge>
) : (
<Badge
className="border border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400"
data-testid="agent-readiness-badge"
variant="outline"
>
Not configured
</Badge>
)}
<Button
className="h-7 gap-1.5 text-xs text-muted-foreground hover:text-foreground"
data-testid="agent-readiness-recheck"
disabled={runtimesQuery.isFetching}
onClick={() => void runtimesQuery.refetch()}
size="sm"
type="button"
variant="ghost"
>
{runtimesQuery.isFetching ? (
<Spinner className="h-3 w-3 border-[1.5px]" />
) : (
<RefreshCw className="h-3 w-3" />
)}
Re-check
</Button>
</div>
</div>
{isLoading ? (
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<Spinner className="h-4 w-4 border-2" />
Loading…
</div>
) : (
<GlobalAgentConfigFields
bakedEnv={bakedEnv}
buzzAgentRuntime={buzzAgentRuntime}
config={config}
isCustomModelEditing={isCustomModelEditing}
isCustomProvider={isCustomProvider}
onConfigChange={(next) => {
// Always apply optimistically so the UI never reverts mid-save,
// then enqueue the persist — the coalescer serialises multiple
// rapid edits into a single trailing request.
setConfig(next);
coalescerRef.current?.enqueue(next);
}}
onCustomModelEditingChange={setIsCustomModelEditing}
onIsCustomProviderChange={setIsCustomProvider}
/>
)}
{!readiness.ready ? (
<p className="text-sm text-muted-foreground">
You can finish now and configure agents later in Settings.
</p>
) : null}
</section>
);
}
function useSetupStepState(): SetupStepState {
const runtimesQuery = useAcpRuntimesQuery();
const items = runtimesQuery.data ?? [];
@@ -418,6 +577,8 @@ function SetupStepContent({
>
<RuntimeProvidersSection runtimeProviders={runtimeProviders} />
<AgentDefaultsSection />
<div className="mx-auto flex w-full max-w-md flex-col gap-3">
<Button
className="h-10 w-full"

View File

@@ -0,0 +1,218 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveAgentReadiness } from "./agentReadiness.ts";
// Minimal stub helpers.
function makeRuntime(overrides = {}) {
return {
id: "goose",
label: "Goose",
availability: "available",
authStatus: { status: "logged_in" },
avatarUrl: "",
command: "goose",
binaryPath: "/usr/local/bin/goose",
defaultArgs: [],
mcpCommand: null,
installHint: "",
installInstructionsUrl: "https://example.com",
canAutoInstall: false,
underlyingCliPath: null,
nodeRequired: false,
loginHint: null,
...overrides,
};
}
function makeConfig(overrides = {}) {
return {
env_vars: {},
provider: null,
model: null,
...overrides,
};
}
// ---------------------------------------------------------------------------
// CLI path
// ---------------------------------------------------------------------------
test("resolveAgentReadiness_cli_returns_ready_when_cli_runtime_available_and_logged_in", () => {
const runtimes = [makeRuntime({ id: "goose", label: "Goose" })];
const result = resolveAgentReadiness(runtimes, makeConfig());
assert.deepEqual(result, {
ready: true,
reason: "cli",
runtimeLabel: "Goose",
});
});
test("resolveAgentReadiness_cli_uses_first_matching_runtime", () => {
const runtimes = [
makeRuntime({ id: "claude", label: "Claude" }),
makeRuntime({ id: "goose", label: "Goose" }),
];
const result = resolveAgentReadiness(runtimes, makeConfig());
assert.equal(result.ready, true);
if (result.ready) {
assert.equal(result.reason, "cli");
assert.equal(result.runtimeLabel, "Claude");
}
});
test("resolveAgentReadiness_cli_skips_logged_out_runtimes", () => {
const runtimes = [
makeRuntime({
id: "goose",
label: "Goose",
authStatus: { status: "logged_out" },
}),
];
const result = resolveAgentReadiness(runtimes, makeConfig());
assert.equal(result.ready, false);
});
test("resolveAgentReadiness_cli_ready_for_login_free_harness_with_not_applicable_auth", () => {
// Goose uses not_applicable because it has no login concept; it should
// still show green if the runtime is available.
const runtimes = [
makeRuntime({
id: "goose",
label: "Goose",
availability: "available",
authStatus: { status: "not_applicable" },
}),
];
const result = resolveAgentReadiness(runtimes, makeConfig());
assert.deepEqual(result, {
ready: true,
reason: "cli",
runtimeLabel: "Goose",
});
});
test("resolveAgentReadiness_cli_not_ready_for_unknown_auth_status", () => {
// unknown means auth state hasn't been determined yet — conservative.
const runtimes = [
makeRuntime({
id: "goose",
label: "Goose",
availability: "available",
authStatus: { status: "unknown" },
}),
];
const result = resolveAgentReadiness(runtimes, makeConfig());
assert.equal(result.ready, false);
});
test("resolveAgentReadiness_cli_not_ready_for_config_invalid_auth_status", () => {
const runtimes = [
makeRuntime({
id: "goose",
label: "Goose",
availability: "available",
authStatus: { status: "config_invalid" },
}),
];
const result = resolveAgentReadiness(runtimes, makeConfig());
assert.equal(result.ready, false);
});
test("resolveAgentReadiness_cli_skips_unavailable_runtimes", () => {
const runtimes = [
makeRuntime({
id: "goose",
label: "Goose",
availability: "not_installed",
authStatus: { status: "logged_in" },
}),
];
const result = resolveAgentReadiness(runtimes, makeConfig());
assert.equal(result.ready, false);
});
test("resolveAgentReadiness_cli_ignores_buzz_agent_runtime", () => {
// buzz-agent with availability=available and logged_in must NOT trigger the CLI path.
const runtimes = [
makeRuntime({
id: "buzz-agent",
label: "buzz-agent",
authStatus: { status: "not_applicable" },
}),
];
const result = resolveAgentReadiness(runtimes, makeConfig());
assert.equal(result.ready, false);
});
// ---------------------------------------------------------------------------
// buzz-agent path
// ---------------------------------------------------------------------------
test("resolveAgentReadiness_buzz_agent_ready_when_provider_model_and_key_set", () => {
// anthropic requires ANTHROPIC_API_KEY
const config = makeConfig({
provider: "anthropic",
model: "claude-3-5-sonnet-latest",
env_vars: { ANTHROPIC_API_KEY: "sk-ant-test" },
});
const result = resolveAgentReadiness([], config);
assert.deepEqual(result, { ready: true, reason: "buzz-agent" });
});
test("resolveAgentReadiness_buzz_agent_not_ready_when_missing_required_credential_key", () => {
const config = makeConfig({
provider: "anthropic",
model: "claude-3-5-sonnet-latest",
env_vars: {},
});
const result = resolveAgentReadiness([], config);
assert.equal(result.ready, false);
});
test("resolveAgentReadiness_buzz_agent_not_ready_when_provider_missing", () => {
const config = makeConfig({
provider: null,
model: "claude-3-5-sonnet-latest",
env_vars: { ANTHROPIC_API_KEY: "sk-ant-test" },
});
const result = resolveAgentReadiness([], config);
assert.equal(result.ready, false);
});
test("resolveAgentReadiness_buzz_agent_not_ready_when_model_missing", () => {
const config = makeConfig({
provider: "anthropic",
model: null,
env_vars: { ANTHROPIC_API_KEY: "sk-ant-test" },
});
const result = resolveAgentReadiness([], config);
assert.equal(result.ready, false);
});
// ---------------------------------------------------------------------------
// Neither path ready
// ---------------------------------------------------------------------------
test("resolveAgentReadiness_neither_returns_not_ready", () => {
const result = resolveAgentReadiness([], makeConfig());
assert.deepEqual(result, { ready: false });
});
// ---------------------------------------------------------------------------
// CLI path takes priority over buzz-agent path
// ---------------------------------------------------------------------------
test("resolveAgentReadiness_cli_wins_over_buzz_agent_when_both_ready", () => {
const runtimes = [makeRuntime({ id: "goose", label: "Goose" })];
const config = makeConfig({
provider: "anthropic",
model: "claude-3-5-sonnet-latest",
env_vars: { ANTHROPIC_API_KEY: "sk-ant-test" },
});
const result = resolveAgentReadiness(runtimes, config);
assert.equal(result.ready, true);
if (result.ready) {
assert.equal(result.reason, "cli");
}
});

View File

@@ -0,0 +1,53 @@
import { requiredCredentialEnvKeys } from "@/features/agents/ui/personaDialogPickers";
import type {
AcpRuntimeCatalogEntry,
GlobalAgentConfig,
} from "@/shared/api/types";
export type AgentReadinessResult =
| { ready: true; reason: "cli"; runtimeLabel: string }
| { ready: true; reason: "buzz-agent" }
| { ready: false };
/**
* Determine whether the user has a working agent path configured.
*
* CLI path: at least one non-buzz-agent runtime is available and logged in.
* buzz-agent path: provider and model are set, and all required credential
* env vars for that provider are present.
*
* Returns enough info for the UI to say which path matched, or that neither did.
*/
export function resolveAgentReadiness(
runtimes: readonly AcpRuntimeCatalogEntry[],
globalConfig: GlobalAgentConfig,
): AgentReadinessResult {
// CLI path — any non-buzz-agent runtime that is available and has an
// auth status indicating it can run: logged_in for runtimes that require
// auth (Claude Code), or not_applicable for login-free harnesses (Goose).
for (const runtime of runtimes) {
if (runtime.id === "buzz-agent") continue;
if (
runtime.availability === "available" &&
(runtime.authStatus.status === "logged_in" ||
runtime.authStatus.status === "not_applicable")
) {
return { ready: true, reason: "cli", runtimeLabel: runtime.label };
}
}
// buzz-agent path — provider + model + required credential keys all present.
const provider = globalConfig.provider?.trim() ?? "";
const model = globalConfig.model?.trim() ?? "";
if (provider.length > 0 && model.length > 0) {
const required = requiredCredentialEnvKeys("buzz-agent", provider);
const allKeysPresent = required.every(
(key) => (globalConfig.env_vars[key] ?? "").trim().length > 0,
);
if (allKeysPresent) {
return { ready: true, reason: "buzz-agent" };
}
}
return { ready: false };
}

View File

@@ -0,0 +1,215 @@
/**
* Tests for the onboarding step-count and routing logic that was added for the
* key-backup feature. These are pure-logic tests — no React rendering needed.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { backupNextDisabled } from "./BackupStep.tsx";
// Mirrors the FRESH_STEPS / IMPORT_STEPS arrays in OnboardingFlow.tsx.
// key-import is normalised to "profile" before the indexOf lookup.
const FRESH_STEPS = ["profile", "backup", "avatar", "theme", "setup"];
const IMPORT_STEPS = ["profile", "avatar", "theme", "setup"];
const STEP_OFFSET = 2;
/**
* Mirrors the currentStep derivation in OnboardingFlow.tsx.
* Fresh path: profile(2) → backup(3) → avatar(4) → theme(5) → setup(6)
* Imported path: profile(2) → avatar(3) → theme(4) → setup(5)
*/
function computeCurrentStep(page, identityWasImported) {
const steps = identityWasImported ? IMPORT_STEPS : FRESH_STEPS;
const normalizedPage = page === "key-import" ? "profile" : page;
const idx = steps.indexOf(normalizedPage);
return idx >= 0 ? idx + STEP_OFFSET : STEP_OFFSET;
}
function computeTotalSteps(identityWasImported) {
const steps = identityWasImported ? IMPORT_STEPS : FRESH_STEPS;
return steps.length + 1;
}
// ---------------------------------------------------------------------------
// Total step count
// ---------------------------------------------------------------------------
test("totalSteps_is_5_when_identity_was_imported", () => {
assert.equal(computeTotalSteps(true), 5);
});
test("totalSteps_is_6_on_fresh_key_path", () => {
assert.equal(computeTotalSteps(false), 6);
});
// ---------------------------------------------------------------------------
// Step numbers — fresh path (6 steps)
// ---------------------------------------------------------------------------
test("currentStep_profile_is_2_on_fresh_path", () => {
assert.equal(computeCurrentStep("profile", false), 2);
});
test("currentStep_key_import_is_2_on_fresh_path", () => {
assert.equal(computeCurrentStep("key-import", false), 2);
});
test("currentStep_backup_is_3_on_fresh_path", () => {
assert.equal(computeCurrentStep("backup", false), 3);
});
test("currentStep_avatar_is_4_on_fresh_path", () => {
assert.equal(computeCurrentStep("avatar", false), 4);
});
test("currentStep_theme_is_5_on_fresh_path", () => {
assert.equal(computeCurrentStep("theme", false), 5);
});
test("currentStep_setup_is_6_on_fresh_path", () => {
assert.equal(computeCurrentStep("setup", false), 6);
});
// ---------------------------------------------------------------------------
// Step numbers — imported key path (5 steps)
// ---------------------------------------------------------------------------
test("currentStep_profile_is_2_on_imported_path", () => {
assert.equal(computeCurrentStep("profile", true), 2);
});
test("currentStep_avatar_is_3_on_imported_path", () => {
assert.equal(computeCurrentStep("avatar", true), 3);
});
test("currentStep_theme_is_4_on_imported_path", () => {
assert.equal(computeCurrentStep("theme", true), 4);
});
test("currentStep_setup_is_5_on_imported_path", () => {
assert.equal(computeCurrentStep("setup", true), 5);
});
// ---------------------------------------------------------------------------
// Routing: profile submit goes to backup (fresh) or avatar (imported)
// ---------------------------------------------------------------------------
test("profile_submit_routes_to_backup_on_fresh_path", () => {
const identityWasImported = false;
const nextPage = identityWasImported ? "avatar" : "backup";
assert.equal(nextPage, "backup");
});
test("profile_submit_routes_to_avatar_on_imported_path", () => {
const identityWasImported = true;
const nextPage = identityWasImported ? "avatar" : "backup";
assert.equal(nextPage, "avatar");
});
// ---------------------------------------------------------------------------
// BackupStep gating: backupNextDisabled() pure helper
// ---------------------------------------------------------------------------
test("backup_next_disabled_while_loading", () => {
// During a slow keychain read, Next must be blocked — user cannot race past
// the key display before it is shown.
assert.equal(
backupNextDisabled({
isLoading: true,
loadError: null,
nsec: null,
hasAcknowledged: false,
}),
true,
);
});
test("backup_next_disabled_on_load_error", () => {
// Error state: only the explicit "Skip for now" ghost advances; Next blocked.
assert.equal(
backupNextDisabled({
isLoading: false,
loadError: "IPC error",
nsec: null,
hasAcknowledged: false,
}),
true,
);
});
test("backup_next_disabled_when_nsec_loaded_and_not_acknowledged", () => {
// Key shown but checkbox unchecked.
assert.equal(
backupNextDisabled({
isLoading: false,
loadError: null,
nsec: "nsec1test",
hasAcknowledged: false,
}),
true,
);
});
test("backup_next_enabled_when_nsec_loaded_and_acknowledged", () => {
// Key shown and checkbox checked — the normal happy path.
assert.equal(
backupNextDisabled({
isLoading: false,
loadError: null,
nsec: "nsec1test",
hasAcknowledged: true,
}),
false,
);
});
test("backup_next_enabled_when_backend_returned_null_key_cleanly", () => {
// Backend returned no key without an error (edge case): nothing to acknowledge,
// allow forward progress so onboarding is never bricked.
assert.equal(
backupNextDisabled({
isLoading: false,
loadError: null,
nsec: null,
hasAcknowledged: false,
}),
false,
);
});
// ---------------------------------------------------------------------------
// Avatar skip button visibility logic
// ---------------------------------------------------------------------------
test("always_skip_shows_skip_button_when_no_error", () => {
const showAlwaysSkip = true;
const errorMessage = null;
const canSkipForNow = false;
const showSkip = canSkipForNow || (showAlwaysSkip && errorMessage === null);
assert.equal(showSkip, true);
});
test("always_skip_hides_skip_button_when_error_is_present", () => {
// On error, the error-recovery buttons take over (canAdvanceWithoutSaving)
const showAlwaysSkip = true;
const errorMessage = "Save failed";
const canSkipForNow = false;
const showSkip = canSkipForNow || (showAlwaysSkip && errorMessage === null);
assert.equal(showSkip, false);
});
test("error_recovery_shows_skip_button_regardless_of_always_skip", () => {
const showAlwaysSkip = false;
const errorMessage = null;
const canSkipForNow = true;
const showSkip = canSkipForNow || (showAlwaysSkip && errorMessage === null);
assert.equal(showSkip, true);
});
test("skip_button_hidden_when_no_error_and_always_skip_false", () => {
const showAlwaysSkip = false;
const errorMessage = null;
const canSkipForNow = false;
const showSkip = canSkipForNow || (showAlwaysSkip && errorMessage === null);
assert.equal(showSkip, false);
});

View File

@@ -0,0 +1,201 @@
/**
* Unit tests for the save coalescer helper.
*
* Each test controls the in-flight save duration with a deferred promise so
* it can precisely verify interleaving: one edit during flight, multiple
* overwrites, cancellation, etc.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { createSaveCoalescer } from "./saveCoalescer.ts";
function deferred() {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
// Drain one microtask queue turn so the async drain() loop can advance.
const tick = () => new Promise((r) => setTimeout(r, 0));
test("saveCoalescer_single_edit_is_persisted_and_saved_is_applied", async () => {
const persisted = [];
const saved = [];
const coalescer = createSaveCoalescer(
async (v) => {
persisted.push(v);
return { ...v, fromServer: true };
},
() => {},
(v) => saved.push(v),
);
coalescer.enqueue({ model: "claude" });
await tick();
await tick();
assert.equal(persisted.length, 1);
assert.equal(persisted[0].model, "claude");
assert.equal(saved.length, 1);
assert.equal(saved[0].fromServer, true);
});
test("saveCoalescer_rapid_edits_coalesce_second_is_drained_after_first", async () => {
const d = deferred();
const persisted = [];
const coalescer = createSaveCoalescer(
async (v) => {
if (v.n === 0) await d.promise;
persisted.push(v.n);
return v;
},
() => {},
() => {},
);
coalescer.enqueue({ n: 0 });
// Second edit arrives while first save is still in flight.
coalescer.enqueue({ n: 1 });
d.resolve();
await tick();
await tick();
assert.deepEqual(persisted, [0, 1]);
});
test("saveCoalescer_three_rapid_edits_only_first_and_last_are_persisted", async () => {
const d = deferred();
const persisted = [];
const coalescer = createSaveCoalescer(
async (v) => {
if (v.n === 0) await d.promise;
persisted.push(v.n);
return v;
},
() => {},
() => {},
);
coalescer.enqueue({ n: 0 });
coalescer.enqueue({ n: 1 }); // overwritten before drain picks it up
coalescer.enqueue({ n: 2 }); // overwrites n:1
d.resolve();
await tick();
await tick();
// n:1 was never the pending value when the drain loop checked; only n:2.
assert.deepEqual(persisted, [0, 2]);
});
test("saveCoalescer_onSaved_suppressed_for_first_when_second_is_pending", async () => {
const d = deferred();
const savedCalls = [];
const coalescer = createSaveCoalescer(
async (v) => {
if (v.n === 0) await d.promise;
return v;
},
() => {},
(v) => savedCalls.push(v.n),
);
coalescer.enqueue({ n: 0 });
// Queue n:1 before n:0 resolves — onSaved for n:0 should be suppressed.
coalescer.enqueue({ n: 1 });
d.resolve();
await tick();
await tick();
// Only n:1 (the final save round) triggers onSaved.
assert.deepEqual(savedCalls, [1]);
});
test("saveCoalescer_isSaving_transitions_true_then_false", async () => {
const d = deferred();
const states = [];
const coalescer = createSaveCoalescer(
async (v) => {
await d.promise;
return v;
},
(s) => states.push(s),
() => {},
);
coalescer.enqueue({ n: 0 });
assert.deepEqual(states, [true]);
d.resolve();
await tick();
await tick();
assert.deepEqual(states, [true, false]);
});
test("saveCoalescer_cancel_prevents_onSaved_and_onSaving_false", async () => {
const d = deferred();
const states = [];
const savedCalls = [];
const coalescer = createSaveCoalescer(
async (v) => {
await d.promise;
return v;
},
(s) => states.push(s),
(v) => savedCalls.push(v),
);
coalescer.enqueue({ n: 0 });
coalescer.cancel();
d.resolve();
await tick();
await tick();
// After cancel, neither onSaved nor onSaving(false) fire.
assert.equal(savedCalls.length, 0);
assert.equal(states.includes(false), false);
});
test("saveCoalescer_save_error_does_not_call_onSaved_but_drains_pending", async () => {
const d = deferred();
const persisted = [];
const savedCalls = [];
const coalescer = createSaveCoalescer(
async (v) => {
if (v.n === 0) {
await d.promise;
throw new Error("network error");
}
persisted.push(v.n);
return v;
},
() => {},
(v) => savedCalls.push(v.n),
);
coalescer.enqueue({ n: 0 });
coalescer.enqueue({ n: 1 }); // pending while n:0 fails
d.resolve();
await tick();
await tick();
// n:0 errored — no onSaved for it; n:1 was drained and saved.
assert.deepEqual(persisted, [1]);
assert.deepEqual(savedCalls, [1]);
});

View File

@@ -0,0 +1,57 @@
/**
* Creates an async save coalescer.
*
* When multiple calls to enqueue() arrive while a save is in flight, only
* the latest enqueued value is submitted per drain round — no edit is
* silently dropped and the final persisted state always reflects the most
* recent local change.
*
* Lifecycle: call cancel() on unmount so in-flight saves do not invoke
* the onSaving / onSaved callbacks after the owning component is gone.
*/
export function createSaveCoalescer<T>(
save: (value: T) => Promise<T>,
onSaving: (isSaving: boolean) => void,
onSaved: (value: T) => void,
): { enqueue: (value: T) => void; cancel: () => void } {
let pending: T | undefined;
let hasPending = false;
let running = false;
let cancelled = false;
async function drain() {
while (hasPending) {
const toSave = pending as T;
hasPending = false;
pending = undefined;
try {
const saved = await save(toSave);
// Apply backend response only when no newer local edit is pending —
// a stale response must never overwrite fresher optimistic state.
if (!cancelled && !hasPending) {
onSaved(saved);
}
} catch {
// Non-fatal — optimistic local state is retained; user can retry.
}
}
running = false;
if (!cancelled) onSaving(false);
}
return {
enqueue(value: T) {
pending = value;
hasPending = true;
if (running) return;
running = true;
onSaving(true);
void drain();
},
cancel() {
cancelled = true;
hasPending = false;
pending = undefined;
},
};
}

View File

@@ -3,6 +3,7 @@ import type { AcpRuntimeCatalogEntry, Profile } from "@/shared/api/types";
export type OnboardingPage =
| "profile"
| "key-import"
| "backup"
| "avatar"
| "theme"
| "setup"

View File

@@ -20,51 +20,18 @@ import type { GlobalAgentConfig } from "@/shared/api/types";
import { getBakedBuildEnv, type BakedEnvEntry } from "@/shared/api/tauri";
import { globalAgentConfigQueryKey } from "@/features/agents/useGlobalAgentConfig";
import { useAcpRuntimesQuery } from "@/features/agents/hooks";
import { EnvVarsEditor } from "@/features/agents/ui/EnvVarsEditor";
import type { InheritedEnvRow } from "@/features/agents/ui/EnvVarsEditor";
import { getBakedProviderInheritLabel } from "@/features/agents/ui/bakedEnvHelpers";
import {
AUTO_PROVIDER_DROPDOWN_VALUE,
BLOCK_BUILD_HIDDEN_PROVIDER_IDS,
CUSTOM_PROVIDER_DROPDOWN_VALUE,
getPersonaProviderOptions,
} from "@/features/agents/ui/personaDialogPickers";
import { AgentModelField } from "@/features/agents/ui/personaProviderModelFields";
import { usePersonaModelDiscovery } from "@/features/agents/ui/usePersonaModelDiscovery";
import {
BUZZ_AGENT_THINKING_EFFORT,
getProviderEffortConfig,
} from "@/features/agents/ui/buzzAgentConfig";
import {
EffortSelectField,
useEffortAutoClear,
} from "@/features/agents/ui/buzzAgentModelTuningFields";
GlobalAgentConfigFields,
EMPTY_GLOBAL_CONFIG,
} from "@/features/agents/ui/GlobalAgentConfigFields";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
import { SettingsOptionGroup } from "./SettingsOptionGroup";
const EMPTY_CONFIG: GlobalAgentConfig = {
env_vars: {},
provider: null,
model: null,
};
/**
* Baked env keys that map to structured fields (provider/model/effort dropdowns).
* These are routed to their own UI controls and must NOT appear as generic
* inherited rows in the env editor.
*/
const BAKED_STRUCTURED_KEYS = new Set([
"BUZZ_AGENT_PROVIDER",
"BUZZ_AGENT_MODEL",
BUZZ_AGENT_THINKING_EFFORT,
]);
type SaveState = "idle" | "saving" | "saved" | "error";
export function GlobalAgentConfigSettingsCard() {
const [config, setConfig] = React.useState<GlobalAgentConfig>(EMPTY_CONFIG);
const [config, setConfig] =
React.useState<GlobalAgentConfig>(EMPTY_GLOBAL_CONFIG);
const [dirty, setDirty] = React.useState(false);
const [saveState, setSaveState] = React.useState<SaveState>("idle");
const [saveError, setSaveError] = React.useState<string | null>(null);
@@ -116,112 +83,15 @@ export function GlobalAgentConfigSettingsCard() {
});
}, []);
// Derive structured-field values and generic env rows from bakedEnv.
// Structured keys (BUZZ_AGENT_PROVIDER / BUZZ_AGENT_MODEL / BUZZ_AGENT_THINKING_EFFORT)
// route to their respective dropdown controls and are excluded from the
// generic inherited-rows list passed to EnvVarsEditor.
const bakedProvider = React.useMemo(
() => bakedEnv.find((e) => e.key === "BUZZ_AGENT_PROVIDER")?.value ?? null,
[bakedEnv],
);
const bakedModel = React.useMemo(
() => bakedEnv.find((e) => e.key === "BUZZ_AGENT_MODEL")?.value ?? null,
[bakedEnv],
);
const bakedEffort = React.useMemo(
() =>
bakedEnv.find((e) => e.key === BUZZ_AGENT_THINKING_EFFORT)?.value ?? null,
[bakedEnv],
);
const bakedGenericRows = React.useMemo<readonly InheritedEnvRow[]>(
() => bakedEnv.filter((e) => !BAKED_STRUCTURED_KEYS.has(e.key)),
[bakedEnv],
);
const bakedEnvKeys = React.useMemo(
() => bakedEnv.map((e) => e.key),
[bakedEnv],
);
// Resolve the buzz-agent runtime catalog entry for model discovery.
// The card is always visible (open=true), so the query is always enabled.
const runtimesQuery = useAcpRuntimesQuery();
const buzzAgentRuntime = React.useMemo(
() => (runtimesQuery.data ?? []).find((r) => r.id === "buzz-agent"),
[runtimesQuery.data],
);
// Provider value used for discovery — empty string when custom provider text
// field is being edited (discovery can't run against a partial/uncommitted value).
const providerValue = config.provider ?? "";
const providerForDiscovery = isCustomProvider ? "" : providerValue;
const {
discoveredModelOptions,
modelDiscoveryLoading,
modelDiscoveryStatus,
} = usePersonaModelDiscovery({
envVars: config.env_vars,
isCustomProviderEditing: isCustomProvider,
modelFieldVisible: true,
open: true,
provider: providerForDiscovery,
selectedRuntime: buzzAgentRuntime,
});
// Auto-clear BUZZ_AGENT_THINKING_EFFORT when provider/model change makes the
// current value invalid. Prevents stale invalid values from being saved.
const currentEffortForAutoClear =
config.env_vars[BUZZ_AGENT_THINKING_EFFORT] ?? "";
const { validValues: effortValidForAutoClear } = getProviderEffortConfig(
config.provider ?? "",
config.model ?? "",
);
useEffortAutoClear({
currentEffort: currentEffortForAutoClear,
effortValid: effortValidForAutoClear,
onClear: () => {
setConfig((prev) => {
const nextEnvVars = { ...prev.env_vars };
delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT];
return { ...prev, env_vars: nextEnvVars };
});
setDirty(true);
},
});
function handleEnvVarsChange(next: Record<string, string>) {
setConfig((prev) => ({ ...prev, env_vars: next }));
setDirty(true);
setSaveState("idle");
setSaveError(null);
}
function handleProviderChange(value: string) {
if (value === CUSTOM_PROVIDER_DROPDOWN_VALUE) {
setIsCustomProvider(true);
return;
}
if (value === AUTO_PROVIDER_DROPDOWN_VALUE || value === "") {
setIsCustomProvider(false);
setConfig((prev) => ({ ...prev, provider: null }));
} else {
setIsCustomProvider(false);
setConfig((prev) => ({ ...prev, provider: value }));
}
setDirty(true);
setSaveState("idle");
setSaveError(null);
}
function handleCustomProviderInput(value: string) {
setConfig((prev) => ({ ...prev, provider: value || null }));
setDirty(true);
setSaveState("idle");
setSaveError(null);
}
function handleModelChange(value: string) {
setConfig((prev) => ({ ...prev, model: value || null }));
function handleConfigChange(next: GlobalAgentConfig) {
setConfig(next);
setDirty(true);
setSaveState("idle");
setSaveError(null);
@@ -262,35 +132,6 @@ export function GlobalAgentConfigSettingsCard() {
}
}
// On internal Block builds, BUZZ_AGENT_PROVIDER is baked in and a boot
// migration rewrites v1→v2. Hide the legacy v1 option so it is not offered
// for new selections; OSS builds show it.
const hideProviderIds = React.useMemo(
() =>
bakedEnvKeys.includes("BUZZ_AGENT_PROVIDER")
? BLOCK_BUILD_HIDDEN_PROVIDER_IDS
: new Set<string>(),
[bakedEnvKeys],
);
const providerOptions = getPersonaProviderOptions(
providerValue,
"buzz-agent",
undefined,
hideProviderIds,
);
const providerSelectValue = isCustomProvider
? CUSTOM_PROVIDER_DROPDOWN_VALUE
: providerValue || AUTO_PROVIDER_DROPDOWN_VALUE;
// When a baked provider is present and no explicit global provider is set,
// relabel the zero-value option to surface the inherited-from-build value.
// When an explicit provider IS set, the zero-value option is still shown in
// the list but never selected — its label doesn't matter.
const providerZeroLabel = React.useMemo(() => {
if (!bakedProvider) return null;
return getBakedProviderInheritLabel(bakedProvider, providerOptions);
}, [bakedProvider, providerOptions]);
return (
<section className="min-w-0" data-testid="settings-global-agent-config">
<SettingsSectionHeader
@@ -309,141 +150,16 @@ export function GlobalAgentConfigSettingsCard() {
Failed to load agent defaults. Restart the app to try again.
</div>
) : (
<SettingsOptionGroup>
{/* Provider field */}
<div className="space-y-1.5 p-3">
<label
className="text-sm font-medium"
htmlFor="global-agent-provider"
>
Default LLM provider
</label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs"
id="global-agent-provider"
onChange={(e) => handleProviderChange(e.target.value)}
value={providerSelectValue}
>
{providerOptions.map((opt) => (
<option
key={opt.id}
value={opt.id || AUTO_PROVIDER_DROPDOWN_VALUE}
>
{opt.id === "" ? (providerZeroLabel ?? opt.label) : opt.label}
</option>
))}
<option value={CUSTOM_PROVIDER_DROPDOWN_VALUE}>
Custom provider…
</option>
</select>
{isCustomProvider ? (
<Input
aria-label="Custom global provider ID"
autoCorrect="off"
onChange={(e) => handleCustomProviderInput(e.target.value)}
placeholder="Custom provider ID"
value={providerValue}
/>
) : null}
<p className="text-xs text-muted-foreground">
Applies to all agents that don't have a per-agent provider set.
</p>
</div>
{/* Model field */}
<div className="space-y-1.5 p-3">
<AgentModelField
disabled={false}
discoveredModelOptions={discoveredModelOptions}
globalModel={bakedModel ?? undefined}
id="global-agent-model"
isCustomModelEditing={isCustomModelEditing}
isRequired={false}
model={config.model ?? ""}
modelDiscoveryLoading={modelDiscoveryLoading}
modelDiscoveryStatus={modelDiscoveryStatus}
onIsCustomModelEditingChange={setIsCustomModelEditing}
onModelChange={(value) => handleModelChange(value)}
/>
<p className="text-xs text-muted-foreground">
Applies to all agents that don't have a per-agent model set.
</p>
</div>
{/* Thinking / Effort — tier-1 dropdown, single editable surface for BUZZ_AGENT_THINKING_EFFORT */}
<div className="p-3">
{(() => {
const { validValues: effortValid, defaultValue: effortDefault } =
getProviderEffortConfig(
config.provider ?? "",
config.model ?? "",
);
const currentEffort =
config.env_vars[BUZZ_AGENT_THINKING_EFFORT] ?? "";
return (
<>
<EffortSelectField
currentEffort={currentEffort}
effortDefault={effortDefault}
effortValid={effortValid}
htmlFor="global-agent-thinking-effort"
inheritedEffort={bakedEffort ?? undefined}
inheritFallbackLabel={
effortDefault !== null
? `Default (${effortDefault})`
: undefined
}
label="Default thinking / effort"
onChange={(value) => {
setConfig((prev) => {
const nextEnvVars = { ...prev.env_vars };
if (value === "") {
delete nextEnvVars[BUZZ_AGENT_THINKING_EFFORT];
} else {
nextEnvVars[BUZZ_AGENT_THINKING_EFFORT] = value;
}
return { ...prev, env_vars: nextEnvVars };
});
setDirty(true);
setSaveState("idle");
setSaveError(null);
}}
testId="global-agent-thinking-effort-select"
/>
<p className="text-xs text-muted-foreground mt-1.5">
Default thinking/reasoning effort applied to all agents.
Per-agent settings override this.
</p>
</>
);
})()}
</div>
{/* Env vars */}
<div className="p-3">
<EnvVarsEditor
value={Object.fromEntries(
Object.entries(config.env_vars).filter(
([k]) => k !== BUZZ_AGENT_THINKING_EFFORT,
),
)}
onChange={(next) => {
// Merge with the thinking-effort value managed by the tier-1
// dropdown above, preserving it across raw env-var edits.
const effort = config.env_vars[BUZZ_AGENT_THINKING_EFFORT];
const merged =
effort !== undefined
? { ...next, [BUZZ_AGENT_THINKING_EFFORT]: effort }
: next;
handleEnvVarsChange(merged);
}}
inheritedRows={bakedGenericRows}
inheritedRowsLabel="build"
label="Global environment variables"
helperText="Injected into all agents as the lowest-priority layer. Per-agent values override these."
/>
</div>
</SettingsOptionGroup>
<GlobalAgentConfigFields
bakedEnv={bakedEnv}
buzzAgentRuntime={buzzAgentRuntime}
config={config}
isCustomModelEditing={isCustomModelEditing}
isCustomProvider={isCustomProvider}
onConfigChange={handleConfigChange}
onCustomModelEditingChange={setIsCustomModelEditing}
onIsCustomProviderChange={setIsCustomProvider}
/>
)}
{/* Save bar */}

View File

@@ -1,4 +1,4 @@
import { Check, ChevronDown, Copy, Pencil } from "lucide-react";
import { Check, ChevronDown, Copy, Eye, EyeOff, Pencil } from "lucide-react";
import {
AnimatePresence,
LayoutGroup,
@@ -12,6 +12,8 @@ import {
useProfileQuery,
useUpdateProfileMutation,
} from "@/features/profile/hooks";
import { NsecMaskedDisplay } from "@/features/onboarding/ui/NsecMaskedDisplay";
import { getNsec } from "@/shared/api/tauriIdentity";
import { MaskedAvatarBadgeFrame } from "@/features/profile/ui/MaskedAvatarBadgeFrame";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import {
@@ -86,6 +88,92 @@ function IdentityRow({
);
}
/**
* Collapsible row that reveals the user's nsec on demand.
* The nsec is fetched only when first expanded and cleared on collapse.
*/
function NsecRevealRow() {
const [isOpen, setIsOpen] = React.useState(false);
const [nsec, setNsec] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [loadError, setLoadError] = React.useState<string | null>(null);
// Guards against a late-resolving getNsec() repopulating state after Hide
// or after the settings panel unmounts.
const fetchCancelledRef = React.useRef(false);
React.useEffect(() => {
return () => {
fetchCancelledRef.current = true;
setNsec(null);
};
}, []);
async function handleReveal() {
if (!isOpen) {
fetchCancelledRef.current = false;
setIsOpen(true);
setIsLoading(true);
setLoadError(null);
try {
const value = await getNsec();
if (!fetchCancelledRef.current) setNsec(value);
} catch (err) {
if (!fetchCancelledRef.current)
setLoadError(
err instanceof Error
? err.message
: "Failed to retrieve private key.",
);
} finally {
if (!fetchCancelledRef.current) setIsLoading(false);
}
} else {
// Cancel any in-flight fetch before clearing state.
fetchCancelledRef.current = true;
setNsec(null);
setIsOpen(false);
}
}
return (
<div className="px-4 py-3" data-testid="profile-private-key-row">
<div className="flex items-center justify-between gap-4">
<p className="text-sm font-medium">Private key</p>
<button
aria-label={isOpen ? "Hide private key" : "Reveal private key"}
className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-muted px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
data-testid="profile-private-key-toggle"
onClick={() => void handleReveal()}
type="button"
>
{isOpen ? (
<>
<EyeOff className="h-4 w-4 shrink-0" />
Hide
</>
) : (
<>
<Eye className="h-4 w-4 shrink-0" />
Reveal
</>
)}
</button>
</div>
{isOpen ? (
<div className="mt-2">
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : loadError ? (
<p className="text-sm text-destructive">{loadError}</p>
) : nsec ? (
<NsecMaskedDisplay nsec={nsec} />
) : null}
</div>
) : null}
</div>
);
}
function EditProfileMetadataButton({
label,
testId,
@@ -765,6 +853,7 @@ export function ProfileSettingsCard({
testId="profile-nip05"
value={nip05Handle}
/>
<NsecRevealRow />
</div>
</details>
</div>

View File

@@ -199,6 +199,21 @@ type E2eConfig = {
provider: string | null;
model: string | null;
};
/** Delay (ms) applied to `set_global_agent_config` so tests can observe
* autosave behaviour while a request is in flight. 0/undefined = instant.
* Alias of `globalConfigSaveDelayMs` (kept for onboarding specs). */
setGlobalAgentConfigDelayMs?: number;
/**
* When set, `get_nsec` throws with this message instead of returning the
* mock nsec string. Use `nsecErrors` for sequenced failure/success.
*/
nsecError?: string;
/**
* Sequenced results for `get_nsec`. Each element is either a string
* (error message) or null (success — returns the default mock nsec).
* Call N uses results[N]; when exhausted the last entry repeats.
*/
nsecErrors?: (string | null)[];
/**
* The `restarted_count` returned by `set_global_agent_config`. Defaults to
* 0 (no agents restarted). Set to a positive integer to drive the
@@ -6216,6 +6231,9 @@ async function handleDiscoverAcpRuntimes(
// re-evaluated via addInitScript, so the counter starts at 0 for every test.
let installCallCount = 0;
// Per-page get_nsec call counter for sequenced error testing.
let nsecCallCount = 0;
async function handleInstallAcpRuntime(
args: {
runtimeId?: string;
@@ -8285,8 +8303,23 @@ export function maybeInstallE2eTauriMocks() {
return { ...DEFAULT_MOCK_IDENTITY, lost: isLost, locked: isLocked };
}
case "get_nsec":
case "get_nsec": {
const nsecSequence = activeConfig?.mock?.nsecErrors;
if (nsecSequence && nsecSequence.length > 0) {
const idx = Math.min(nsecCallCount, nsecSequence.length - 1);
nsecCallCount++;
const entry = nsecSequence[idx];
if (entry !== null) {
throw new Error(entry);
}
return "nsec1mock000000000000000000000000000000000000000000000000000000";
}
const nsecError = activeConfig?.mock?.nsecError;
if (nsecError) {
throw new Error(nsecError);
}
return "nsec1mock000000000000000000000000000000000000000000000000000000";
}
case "persist_current_identity": {
// Persist the ephemeral key: clears only the lost flag. The locked flag
// is cleared only by import_identity; production rejects
@@ -8782,10 +8815,9 @@ export function maybeInstallE2eTauriMocks() {
);
}
case "set_global_agent_config": {
// In the E2E environment there are no running agents to restart, so
// restarted_count is always 0. Return the submitted config as the
// saved value (mirrors the backend's strip-on-write pass in tests
// where all values are already non-empty).
// Echo back the submitted config as the saved value (mirrors the
// backend's strip-on-write pass in tests where all values are already
// non-empty). The invoke payload wraps it as { config }.
const savedConfig = (
payload as {
config: {
@@ -8796,11 +8828,18 @@ export function maybeInstallE2eTauriMocks() {
}
).config;
// Optional configurable delay so specs can hold a save in flight and
// interleave edits (mid-save race coverage).
const saveDelayMs = config?.mock?.globalConfigSaveDelayMs ?? 0;
// interleave edits (mid-save race + autosave-coalescing coverage).
// Two aliases: onboarding specs use setGlobalAgentConfigDelayMs,
// settings-card specs use globalConfigSaveDelayMs.
const saveDelayMs =
config?.mock?.globalConfigSaveDelayMs ??
activeConfig?.mock?.setGlobalAgentConfigDelayMs ??
0;
if (saveDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, saveDelayMs));
}
// In the E2E environment there are no running agents to restart, so
// the counts default to 0 unless a spec drives them explicitly.
return {
config: savedConfig,
restarted_count: config?.mock?.globalConfigRestartedCount ?? 0,

View File

@@ -0,0 +1,156 @@
import { expect, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
import {
seedActiveIdentity,
passThroughBackupStep,
} from "../helpers/onboarding";
const FIRST_RUN_ALICE = {
...TEST_IDENTITIES.alice,
username: "",
};
const SHOTS = "test-results/screenshots-onboarding";
/** Drive to the setup page (page 2) via the full onboarding flow. */
async function navigateToSetupPage(
page: Parameters<typeof installMockBridge>[0],
) {
await page.getByTestId("onboarding-display-name").fill("Alice");
await page.getByTestId("onboarding-next").click();
await passThroughBackupStep(page);
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page
.getByTestId("onboarding-avatar-url")
.fill("https://example.com/alice.png");
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-theme")).toBeVisible();
await page.getByTestId("onboarding-theme-option-github-light").click();
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
}
test("setup page shows Agent defaults section with readiness badge", async ({
page,
}) => {
await seedActiveIdentity(page, FIRST_RUN_ALICE);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await navigateToSetupPage(page);
const badge = page.getByTestId("agent-readiness-badge");
await expect(badge).toBeVisible();
// Take a screenshot of the entire setup page to capture the readiness badge.
await waitForAnimations(page);
const setupPage = page.locator('[data-testid="onboarding-page-2"]');
await setupPage.screenshot({
path: `${SHOTS}/04-setup-readiness-badge.png`,
});
});
test("setup page shows Not configured badge when no CLI runtime or buzz-agent config", async ({
page,
}) => {
await seedActiveIdentity(page, FIRST_RUN_ALICE);
// Seed empty ACP runtimes so no CLI harness is available.
await installMockBridge(
page,
{ acpRuntimesCatalog: [] },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await navigateToSetupPage(page);
const badge = page.getByTestId("agent-readiness-badge");
await expect(badge).toBeVisible();
await expect(badge).toContainText("Not configured");
// Not-configured warning text should be visible.
await expect(
page.getByText("You can finish now and configure agents later in Settings"),
).toBeVisible();
// Take a screenshot showing the not-configured state.
await waitForAnimations(page);
const setupPage = page.locator('[data-testid="onboarding-page-2"]');
await setupPage.screenshot({
path: `${SHOTS}/05-setup-not-configured.png`,
});
});
test("setup page Re-check button triggers runtimes refetch", async ({
page,
}) => {
await seedActiveIdentity(page, FIRST_RUN_ALICE);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await navigateToSetupPage(page);
const recheckBtn = page.getByTestId("agent-readiness-recheck");
await expect(recheckBtn).toBeVisible();
await expect(recheckBtn).toBeEnabled();
await recheckBtn.click();
// After click the button should still be there (page stays on setup).
await expect(recheckBtn).toBeVisible();
});
test("Finish button is always enabled on setup page regardless of readiness", async ({
page,
}) => {
await seedActiveIdentity(page, FIRST_RUN_ALICE);
await installMockBridge(
page,
{ acpRuntimesCatalog: [] },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await navigateToSetupPage(page);
const finishBtn = page.getByTestId("onboarding-finish");
await expect(finishBtn).toBeVisible();
await expect(finishBtn).toBeEnabled();
});
// ---------------------------------------------------------------------------
// B1 regression: rapid consecutive edits must not lose the later change
// ---------------------------------------------------------------------------
test("rapid consecutive provider changes both survive — later change wins", async ({
page,
}) => {
await seedActiveIdentity(page, FIRST_RUN_ALICE);
// Hold each set_global_agent_config request for 300 ms so the test can
// make a second edit before the first response arrives.
await installMockBridge(
page,
{ acpRuntimesCatalog: [], setGlobalAgentConfigDelayMs: 300 },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await navigateToSetupPage(page);
const providerSelect = page.locator("#global-agent-provider");
await expect(providerSelect).toBeVisible();
// First edit: select OpenAI — save starts, held open for 300 ms.
await providerSelect.selectOption("openai");
// Second edit before first response: select Anthropic. The coalescer must
// persist this as the trailing save, and it must survive in the UI.
await providerSelect.selectOption("anthropic");
// Wait long enough for both saves to complete (2 × 300 ms + margin).
await page.waitForTimeout(800);
// The final provider shown must be Anthropic — neither save must overwrite
// the later optimistic state with a stale response.
await expect(providerSelect).toHaveValue("anthropic");
});

View File

@@ -0,0 +1,135 @@
import { hexToBytes } from "@noble/hashes/utils.js";
import { expect, test } from "@playwright/test";
import { nsecEncode } from "nostr-tools/nip19";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
import {
seedActiveIdentity,
passThroughBackupStep,
} from "../helpers/onboarding";
const BLANK_TYLER_IDENTITY = {
...TEST_IDENTITIES.tyler,
username: "",
};
const SHOTS = "test-results/screenshots-onboarding";
test("avatar step always shows Skip for now button without an error", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await passThroughBackupStep(page);
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
// Skip button must be visible before any avatar is chosen (no error path).
const skipBtn = page.getByTestId("onboarding-skip");
await expect(skipBtn).toBeVisible();
await expect(skipBtn).toBeEnabled();
await expect(skipBtn).toHaveText("Skip for now");
// Capture a screenshot showing the avatar step with the skip button.
await waitForAnimations(page);
const avatarSection = page.locator('[data-testid="onboarding-page-avatar"]');
await avatarSection.screenshot({
path: `${SHOTS}/01-avatar-skip-button.png`,
});
});
test("avatar step skip button routes to theme step", async ({ page }) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await passThroughBackupStep(page);
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page.getByTestId("onboarding-skip").click();
// Skip routes to theme (advanceWithoutSaving → showThemePage).
await expect(page.getByTestId("onboarding-page-theme")).toBeVisible();
});
test("avatar Next button still requires an avatar to be chosen", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await passThroughBackupStep(page);
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
// Next is disabled until an avatar is set.
await expect(page.getByTestId("onboarding-next")).toBeDisabled();
// Once an avatar URL is provided, Next enables.
await page
.getByTestId("onboarding-avatar-url")
.fill("https://example.com/avatar.png");
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
});
// ---------------------------------------------------------------------------
// B4: Routing tests
// ---------------------------------------------------------------------------
test("import-key path skips backup and goes directly to avatar", async ({
page,
}) => {
// Import tyler's OWN key (same pubkey = no component remount) so the
// identityWasImported flag persists in the same component instance.
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
// Profile page — click "Use existing key" to open the key import form.
await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
await page.getByTestId("onboarding-import-key").click();
await expect(
page.getByRole("heading", { name: "Use your existing key" }),
).toBeVisible();
// Enter tyler's own nsec (same pubkey → no remount, identityWasImported stays true).
const tylerNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.tyler.privateKey));
await page.getByTestId("nostr-import-nsec-input").fill(tylerNsec);
await expect(page.getByTestId("nostr-import-npub-preview")).toBeVisible();
await page.getByTestId("nostr-import-submit").click();
// After import, the flow returns to profile with identityWasImported=true.
await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
// Backup page must NOT appear — avatar comes next on the import path.
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await expect(page.getByTestId("onboarding-page-backup")).not.toBeVisible();
});
test("fresh path Back from avatar returns to backup", async ({ page }) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await passThroughBackupStep(page);
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page.getByTestId("onboarding-back").click();
// On the fresh-key path, Back from avatar goes to backup.
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
});

View File

@@ -0,0 +1,167 @@
import { expect, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
import { seedActiveIdentity } from "../helpers/onboarding";
const BLANK_TYLER_IDENTITY = {
...TEST_IDENTITIES.tyler,
username: "",
};
const SHOTS = "test-results/screenshots-onboarding";
test("backup step appears on fresh-key path after profile submit", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Save your private key" }),
).toBeVisible();
});
test("backup step shows masked nsec from mock bridge", async ({ page }) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
const nsecDisplay = page.getByTestId("nsec-value");
await expect(nsecDisplay).toBeVisible();
// Should start masked (blurred) — reveal button exists and eye icon visible.
const revealBtn = page.getByTestId("nsec-reveal-toggle");
await expect(revealBtn).toBeVisible();
await expect(nsecDisplay).toHaveCSS("filter", /blur/);
// Take a screenshot of the masked state.
await waitForAnimations(page);
const backupSection = page.locator('[data-testid="onboarding-page-backup"]');
await backupSection.screenshot({
path: `${SHOTS}/02-backup-step-masked.png`,
});
// Reveal and verify the mock nsec appears.
await revealBtn.click();
await expect(nsecDisplay).not.toHaveCSS("filter", /blur/);
await expect(nsecDisplay).toContainText("nsec1mock");
// Take a screenshot of the revealed state.
await waitForAnimations(page);
await backupSection.screenshot({
path: `${SHOTS}/03-backup-step-revealed.png`,
});
});
test("backup step Next is disabled until checkbox is checked", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await expect(page.getByTestId("nsec-value")).toBeVisible();
// Next is disabled while checkbox is unchecked.
await expect(page.getByTestId("onboarding-next")).toBeDisabled();
// Check the checkbox → Next enables.
await page.getByTestId("backup-acknowledge").check();
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
});
test("backup step advances to avatar on Next click", async ({ page }) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await expect(page.getByTestId("nsec-value")).toBeVisible();
await page.getByTestId("backup-acknowledge").check();
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
});
test("backup step back button returns to profile", async ({ page }) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await page.getByTestId("onboarding-back").click();
await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
});
// ---------------------------------------------------------------------------
// B4: Error path coverage
// ---------------------------------------------------------------------------
test("backup step shows error banner and retry button when get_nsec fails", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{ nsecError: "Keychain locked" },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await expect(page.getByTestId("backup-load-error")).toBeVisible();
await expect(page.getByTestId("backup-retry")).toBeVisible();
// Next is blocked on error; Skip for now ghost is shown instead.
await expect(page.getByTestId("onboarding-next")).toBeDisabled();
await expect(page.getByTestId("backup-skip")).toBeVisible();
// Skip for now still advances to avatar.
await page.getByTestId("backup-skip").click();
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
});
test("backup step retry succeeds and shows key after initial failure", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
// First call fails, second succeeds (sequenced via nsecErrors).
await installMockBridge(
page,
{ nsecErrors: ["Keychain locked", null] },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("backup-load-error")).toBeVisible();
// Retry — second call succeeds.
await page.getByTestId("backup-retry").click();
await expect(page.getByTestId("nsec-value")).toBeVisible();
await expect(page.getByTestId("backup-load-error")).not.toBeVisible();
});

View File

@@ -3,6 +3,11 @@ import { expect, test, type Locator, type Page } from "@playwright/test";
import { nsecEncode } from "nostr-tools/nip19";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import {
E2E_IDENTITY_OVERRIDE_STORAGE_KEY,
seedActiveIdentity,
passThroughBackupStep,
} from "../helpers/onboarding";
type RelayConnectionState =
| "connected"
@@ -58,7 +63,6 @@ async function setRelayConnectionState(
}, state);
}
const E2E_IDENTITY_OVERRIDE_STORAGE_KEY = "buzz:e2e-identity-override.v1";
const HOME_SEEN_STORAGE_KEY_PREFIX = "buzz-home-feed-seen.v1:";
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
const BLANK_TYLER_IDENTITY = {
@@ -80,24 +84,6 @@ const FIRST_RUN_ALICE = {
username: "",
};
type TestIdentity = {
privateKey: string;
pubkey: string;
username: string;
};
async function seedActiveIdentity(page: Page, identity: TestIdentity) {
await page.addInitScript(
({ identity: nextIdentity, storageKey }) => {
window.localStorage.setItem(storageKey, JSON.stringify(nextIdentity));
},
{
identity,
storageKey: E2E_IDENTITY_OVERRIDE_STORAGE_KEY,
},
);
}
async function seedOnboardingCompletion(page: Page, pubkey: string) {
await page.addInitScript(
({ storageKey }) => {
@@ -546,6 +532,7 @@ async function expectIncompleteOnboarding(page: Page) {
async function continueToSetupPage(page: Page) {
await page.getByTestId("onboarding-next").click();
await passThroughBackupStep(page);
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page
.getByTestId("onboarding-avatar-url")
@@ -785,6 +772,7 @@ test("avatar step uses an add-image placeholder before an avatar is chosen", asy
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await passThroughBackupStep(page);
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
const preview = page.getByTestId("onboarding-avatar-preview");
@@ -802,6 +790,7 @@ test("avatar step reveals preset backgrounds after the first emoji pick", async
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await passThroughBackupStep(page);
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page.getByRole("tab", { name: "Emoji" }).click();
@@ -828,6 +817,7 @@ test("avatar step accepts an avatar URL before theme selection", async ({
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await passThroughBackupStep(page);
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page
.getByTestId("onboarding-avatar-url")
@@ -859,6 +849,7 @@ test("failed avatar saves can continue without saving the avatar", async ({
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await passThroughBackupStep(page);
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page
.getByTestId("onboarding-avatar-url")
@@ -891,6 +882,7 @@ test("theme step offers skip instead of going back", async ({ page }) => {
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await passThroughBackupStep(page);
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page
.getByTestId("onboarding-avatar-url")
@@ -972,6 +964,7 @@ test("avatar upload rejects a file whose server-detected MIME is not an image",
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await passThroughBackupStep(page);
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page.getByTestId("onboarding-avatar-input").setInputFiles({
name: "looks-like.png",
@@ -1009,6 +1002,7 @@ test("avatar upload accepts a file whose server-detected MIME is an image", asyn
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await passThroughBackupStep(page);
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page.getByTestId("onboarding-avatar-input").setInputFiles({
name: "avatar.png",

View File

@@ -0,0 +1,65 @@
/**
* Compact E2E tests for NsecRevealRow in ProfileSettingsCard.
* Covers: reveal fetches + renders masked value, error state, collapse clears state.
*/
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
/** Expand the identity details section if it is not already open. */
async function expandIdentity(page: import("@playwright/test").Page) {
const identity = page.getByTestId("profile-identity-card");
const isOpen = await identity.evaluate(
(el) => el instanceof HTMLDetailsElement && el.open,
);
if (!isOpen) {
await page.getByTestId("profile-identity-toggle").click();
}
await expect(page.getByTestId("profile-identity-details")).toBeVisible();
}
test("reveal shows masked nsec value and hides it again on collapse", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await openSettings(page, "profile");
await expandIdentity(page);
const revealToggle = page.getByTestId("profile-private-key-toggle");
await expect(revealToggle).toBeVisible();
await expect(revealToggle).toHaveText("Reveal");
// Reveal — the masked nsec display should appear.
await revealToggle.click();
await expect(revealToggle).toHaveText("Hide");
const nsecDisplay = page.locator(
'[data-testid="profile-private-key-row"] [data-testid="nsec-value"]',
);
await expect(nsecDisplay).toBeVisible();
// The mock bridge returns "nsec1mock…"; the display starts masked (blurred).
await expect(nsecDisplay).toHaveCSS("filter", /blur/);
// Hide — the nsec display should disappear (state cleared).
await revealToggle.click();
await expect(revealToggle).toHaveText("Reveal");
await expect(nsecDisplay).not.toBeVisible();
});
test("reveal shows error when get_nsec fails", async ({ page }) => {
await installMockBridge(page, { nsecError: "Keychain locked" });
await page.goto("/");
await openSettings(page, "profile");
await expandIdentity(page);
const revealToggle = page.getByTestId("profile-private-key-toggle");
await revealToggle.click();
// Error text should appear inside the private-key row.
const keyRow = page.getByTestId("profile-private-key-row");
await expect(keyRow.locator(".text-destructive")).toBeVisible();
await expect(keyRow.locator(".text-destructive")).toContainText(
"Keychain locked",
);
});

View File

@@ -256,6 +256,19 @@ type MockBridgeOptions = {
provider: string | null;
model: string | null;
};
/** Delay (ms) for `set_global_agent_config` — hold saves open in tests.
* Alias of `globalConfigSaveDelayMs` (kept for onboarding specs). */
setGlobalAgentConfigDelayMs?: number;
/**
* When set, `get_nsec` throws with this message. For a single always-fail
* scenario. Use `nsecErrors` for sequenced fail/succeed.
*/
nsecError?: string;
/**
* Sequenced results for `get_nsec`. String = throw with that message;
* null = succeed. Call N uses results[N]; last entry repeats when exhausted.
*/
nsecErrors?: (string | null)[];
/**
* The `restarted_count` returned by `set_global_agent_config`. Defaults to
* 0 (no agents restarted). Set to a positive integer to drive the

View File

@@ -0,0 +1,25 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
export const E2E_IDENTITY_OVERRIDE_STORAGE_KEY =
"buzz:e2e-identity-override.v1";
export async function seedActiveIdentity(
page: Page,
identity: { privateKey: string; pubkey: string; username: string },
) {
await page.addInitScript(
({ identity: nextIdentity, storageKey }) => {
window.localStorage.setItem(storageKey, JSON.stringify(nextIdentity));
},
{ identity, storageKey: E2E_IDENTITY_OVERRIDE_STORAGE_KEY },
);
}
/** Navigate through the backup step (fresh-key path). */
export async function passThroughBackupStep(page: Page) {
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await expect(page.getByTestId("nsec-value")).toBeVisible();
await page.getByTestId("backup-acknowledge").check();
await page.getByTestId("onboarding-next").click();
}