mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(desktop): add key backup and agent defaults to onboarding, make avatar optional (#1767)
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
288
desktop/src/features/agents/ui/GlobalAgentConfigFields.tsx
Normal file
288
desktop/src/features/agents/ui/GlobalAgentConfigFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
222
desktop/src/features/onboarding/ui/BackupStep.tsx
Normal file
222
desktop/src/features/onboarding/ui/BackupStep.tsx
Normal 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 > Profile >
|
||||
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>
|
||||
);
|
||||
}
|
||||
91
desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx
Normal file
91
desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
218
desktop/src/features/onboarding/ui/agentReadiness.test.mjs
Normal file
218
desktop/src/features/onboarding/ui/agentReadiness.test.mjs
Normal 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");
|
||||
}
|
||||
});
|
||||
53
desktop/src/features/onboarding/ui/agentReadiness.ts
Normal file
53
desktop/src/features/onboarding/ui/agentReadiness.ts
Normal 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 };
|
||||
}
|
||||
215
desktop/src/features/onboarding/ui/onboardingFlowSteps.test.mjs
Normal file
215
desktop/src/features/onboarding/ui/onboardingFlowSteps.test.mjs
Normal 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);
|
||||
});
|
||||
201
desktop/src/features/onboarding/ui/saveCoalescer.test.mjs
Normal file
201
desktop/src/features/onboarding/ui/saveCoalescer.test.mjs
Normal 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]);
|
||||
});
|
||||
57
desktop/src/features/onboarding/ui/saveCoalescer.ts
Normal file
57
desktop/src/features/onboarding/ui/saveCoalescer.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { AcpRuntimeCatalogEntry, Profile } from "@/shared/api/types";
|
||||
export type OnboardingPage =
|
||||
| "profile"
|
||||
| "key-import"
|
||||
| "backup"
|
||||
| "avatar"
|
||||
| "theme"
|
||||
| "setup"
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
156
desktop/tests/e2e/onboarding-agent-defaults.spec.ts
Normal file
156
desktop/tests/e2e/onboarding-agent-defaults.spec.ts
Normal 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");
|
||||
});
|
||||
135
desktop/tests/e2e/onboarding-avatar-skip.spec.ts
Normal file
135
desktop/tests/e2e/onboarding-avatar-skip.spec.ts
Normal 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();
|
||||
});
|
||||
167
desktop/tests/e2e/onboarding-backup.spec.ts
Normal file
167
desktop/tests/e2e/onboarding-backup.spec.ts
Normal 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();
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
65
desktop/tests/e2e/profile-nsec-reveal.spec.ts
Normal file
65
desktop/tests/e2e/profile-nsec-reveal.spec.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
25
desktop/tests/helpers/onboarding.ts
Normal file
25
desktop/tests/helpers/onboarding.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user