refactor(onboarding): replace 50-prop drilling with OnboardingContext
OnboardingStepPanel previously forwarded ~50 props to seven step components, so adding a single wizard field meant touching four layers. Introduce a renderer-local React Context (OnboardingProvider / useOnboarding) that carries all wizard state and actions (connectors, node status, company inputs, model selection, execution mode, mission, launch summary, locale, t, error/loading). Step panels now receive only their navigation callbacks (onContinue / onBack / onLaunch / onFinish) and read everything else via useOnboarding(). CompanyOnboarding wraps the step panel tree in OnboardingProvider with a memoised value. Business logic is unchanged: each onContinueFrom* transition, the bootstrap call, and the welcome-step branch remain exactly as before; this refactor only moves the data-passing mechanism.
This commit is contained in:
1 parent
f3816c6115
commit
de77f5f38e
10 files changed
+256
-333
No files matched your search
@@ -1,53 +1,36 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Buildings, FolderOpen } from "@phosphor-icons/react";
|
import { Buildings, FolderOpen } from "@phosphor-icons/react";
|
||||||
import type { ConnectorId, ProfileSnapshot } from "@shared/types";
|
import type { ConnectorId } from "@shared/types";
|
||||||
import type { AppLocale } from "@shared/locale";
|
|
||||||
import { ActionButton, Input, TextArea, Select } from "../ui";
|
import { ActionButton, Input, TextArea, Select } from "../ui";
|
||||||
import { getStepNumber, TOTAL_STEPS } from "./OnboardingHero";
|
import { getStepNumber, TOTAL_STEPS } from "./OnboardingHero";
|
||||||
|
import { useOnboarding } from "./OnboardingContext";
|
||||||
|
|
||||||
interface CompanyStepProps {
|
interface CompanyStepProps {
|
||||||
companyName: string;
|
|
||||||
setCompanyName: (v: string) => void;
|
|
||||||
companyDescription: string;
|
|
||||||
setCompanyDescription: (v: string) => void;
|
|
||||||
selectedConnectorId: ConnectorId | "";
|
|
||||||
setSelectedConnectorId: (v: ConnectorId | "") => void;
|
|
||||||
selectedModel: string;
|
|
||||||
setSelectedModel: (v: string) => void;
|
|
||||||
executionReadyConnectors: ProfileSnapshot["connectors"];
|
|
||||||
modelOptions: Array<{ id: string; label: string }>;
|
|
||||||
loadingModels: boolean;
|
|
||||||
workspacePath: string;
|
|
||||||
setWorkspacePath: (v: string) => void;
|
|
||||||
onPickDirectory: () => void;
|
|
||||||
onContinue: () => void;
|
onContinue: () => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
error: string | null;
|
|
||||||
currentLocale: AppLocale;
|
|
||||||
t: (key: string) => string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CompanyStep({
|
export function CompanyStep({ onContinue, onBack }: CompanyStepProps) {
|
||||||
companyName,
|
const {
|
||||||
setCompanyName,
|
companyName,
|
||||||
companyDescription,
|
setCompanyName,
|
||||||
setCompanyDescription,
|
companyDescription,
|
||||||
selectedConnectorId,
|
setCompanyDescription,
|
||||||
setSelectedConnectorId,
|
selectedConnectorId,
|
||||||
selectedModel,
|
setSelectedConnectorId,
|
||||||
setSelectedModel,
|
selectedModel,
|
||||||
executionReadyConnectors,
|
setSelectedModel,
|
||||||
modelOptions,
|
executionReadyConnectors,
|
||||||
loadingModels,
|
modelOptions,
|
||||||
workspacePath,
|
loadingModels,
|
||||||
setWorkspacePath,
|
workspacePath,
|
||||||
onPickDirectory,
|
setWorkspacePath,
|
||||||
onContinue,
|
onPickDirectory,
|
||||||
onBack,
|
error,
|
||||||
error,
|
currentLocale,
|
||||||
currentLocale,
|
t,
|
||||||
t,
|
} = useOnboarding();
|
||||||
}: CompanyStepProps) {
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="company"
|
key="company"
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { ArrowRight, Brain, UserCircle, type Icon } from "@phosphor-icons/react";
|
import { ArrowRight, Brain, UserCircle, type Icon } from "@phosphor-icons/react";
|
||||||
import type { ExecutionMode } from "@shared/types";
|
|
||||||
import { getStepNumber, TOTAL_STEPS } from "./OnboardingHero";
|
import { getStepNumber, TOTAL_STEPS } from "./OnboardingHero";
|
||||||
|
import { useOnboarding } from "./OnboardingContext";
|
||||||
|
|
||||||
interface ExecutionModeStepProps {
|
interface ExecutionModeStepProps {
|
||||||
executionMode: ExecutionMode;
|
|
||||||
setExecutionMode: (mode: ExecutionMode) => void;
|
|
||||||
onContinue: () => void;
|
onContinue: () => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
t: (key: string) => string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExecutionModeStep({
|
export function ExecutionModeStep({ onContinue, onBack }: ExecutionModeStepProps) {
|
||||||
executionMode,
|
const { executionMode, setExecutionMode, t } = useOnboarding();
|
||||||
setExecutionMode,
|
|
||||||
onContinue,
|
|
||||||
onBack,
|
|
||||||
t,
|
|
||||||
}: ExecutionModeStepProps) {
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="executionmode"
|
key="executionmode"
|
||||||
|
|||||||
@@ -1,25 +1,17 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { CheckCircle, Rocket } from "@phosphor-icons/react";
|
import { CheckCircle, Rocket } from "@phosphor-icons/react";
|
||||||
import type { OnboardingBootstrapResult, ProfileSnapshot } from "@shared/types";
|
|
||||||
import { ActionButton } from "../ui";
|
import { ActionButton } from "../ui";
|
||||||
|
import { useOnboarding } from "./OnboardingContext";
|
||||||
|
|
||||||
interface LaunchStepProps {
|
interface LaunchStepProps {
|
||||||
launchSummary: OnboardingBootstrapResult;
|
|
||||||
selectedConnector: ProfileSnapshot["connectors"][number] | null;
|
|
||||||
onFinish: () => void;
|
onFinish: () => void;
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
t: (key: string) => string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LaunchStep({
|
export function LaunchStep({ onFinish }: LaunchStepProps) {
|
||||||
launchSummary,
|
const { launchSummary, selectedConnector, loading, error, t } = useOnboarding();
|
||||||
selectedConnector,
|
|
||||||
onFinish,
|
if (!launchSummary) return null;
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
t,
|
|
||||||
}: LaunchStepProps) {
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="launch"
|
key="launch"
|
||||||
|
|||||||
@@ -2,30 +2,24 @@ import { motion } from "framer-motion";
|
|||||||
import { Cpu, Lightning } from "@phosphor-icons/react";
|
import { Cpu, Lightning } from "@phosphor-icons/react";
|
||||||
import { ActionButton, Input, TextArea } from "../ui";
|
import { ActionButton, Input, TextArea } from "../ui";
|
||||||
import { getStepNumber, TOTAL_STEPS } from "./OnboardingHero";
|
import { getStepNumber, TOTAL_STEPS } from "./OnboardingHero";
|
||||||
|
import { useOnboarding } from "./OnboardingContext";
|
||||||
|
|
||||||
interface MissionStepProps {
|
interface MissionStepProps {
|
||||||
goalTitle: string;
|
|
||||||
setGoalTitle: (v: string) => void;
|
|
||||||
goalDescription: string;
|
|
||||||
setGoalDescription: (v: string) => void;
|
|
||||||
onLaunch: () => void;
|
onLaunch: () => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
t: (key: string) => string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MissionStep({
|
export function MissionStep({ onLaunch, onBack }: MissionStepProps) {
|
||||||
goalTitle,
|
const {
|
||||||
setGoalTitle,
|
goalTitle,
|
||||||
goalDescription,
|
setGoalTitle,
|
||||||
setGoalDescription,
|
goalDescription,
|
||||||
onLaunch,
|
setGoalDescription,
|
||||||
onBack,
|
loading,
|
||||||
loading,
|
error,
|
||||||
error,
|
t,
|
||||||
t,
|
} = useOnboarding();
|
||||||
}: MissionStepProps) {
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="mission"
|
key="mission"
|
||||||
|
|||||||
@@ -1,39 +1,26 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import type { ConnectorModelEntry } from "@shared/connector-models";
|
|
||||||
import type { ConnectorId, ProfileSnapshot } from "@shared/types";
|
|
||||||
import type { AppLocale } from "@shared/locale";
|
|
||||||
import { ActionButton } from "../ui";
|
import { ActionButton } from "../ui";
|
||||||
import { ModelToggleList, StatusBadge } from "../execution/ModelManagementShared";
|
import { ModelToggleList, StatusBadge } from "../execution/ModelManagementShared";
|
||||||
import { getStepNumber, TOTAL_STEPS } from "./OnboardingHero";
|
import { getStepNumber, TOTAL_STEPS } from "./OnboardingHero";
|
||||||
|
import { useOnboarding } from "./OnboardingContext";
|
||||||
|
|
||||||
interface ModelPoolStepProps {
|
interface ModelPoolStepProps {
|
||||||
currentLocale: AppLocale;
|
|
||||||
connectorModelPools: Array<{
|
|
||||||
connector: ProfileSnapshot["connectors"][number];
|
|
||||||
models: ConnectorModelEntry[];
|
|
||||||
}>;
|
|
||||||
error: string | null;
|
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onContinue: () => void;
|
onContinue: () => void;
|
||||||
onToggleConnectorModel: (connectorId: ConnectorId, modelId: string) => void;
|
|
||||||
selectedConnectorId: ConnectorId | "";
|
|
||||||
selectedConnectorEnabledModels: number;
|
|
||||||
totalEnabledModels: number;
|
|
||||||
t: (key: string) => string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelPoolStep({
|
export function ModelPoolStep({ onBack, onContinue }: ModelPoolStepProps) {
|
||||||
currentLocale,
|
const {
|
||||||
connectorModelPools,
|
currentLocale,
|
||||||
error,
|
connectorModelPools,
|
||||||
onBack,
|
error,
|
||||||
onContinue,
|
onToggleConnectorModel,
|
||||||
onToggleConnectorModel,
|
selectedConnectorId,
|
||||||
selectedConnectorId,
|
selectedConnectorEnabledModels,
|
||||||
selectedConnectorEnabledModels,
|
totalEnabledModels,
|
||||||
totalEnabledModels,
|
t,
|
||||||
t,
|
} = useOnboarding();
|
||||||
}: ModelPoolStepProps) {
|
|
||||||
const selectedConnectorIsRunnable = selectedConnectorEnabledModels > 0;
|
const selectedConnectorIsRunnable = selectedConnectorEnabledModels > 0;
|
||||||
const hasAnyEnabledModels = totalEnabledModels > 0;
|
const hasAnyEnabledModels = totalEnabledModels > 0;
|
||||||
const canContinue = hasAnyEnabledModels && selectedConnectorIsRunnable;
|
const canContinue = hasAnyEnabledModels && selectedConnectorIsRunnable;
|
||||||
|
|||||||
@@ -9,15 +9,11 @@ import {
|
|||||||
import { ActionButton } from "../ui";
|
import { ActionButton } from "../ui";
|
||||||
import type { TerminalCommandKey } from "@shared/contracts";
|
import type { TerminalCommandKey } from "@shared/contracts";
|
||||||
import { getStepNumber, TOTAL_STEPS } from "./OnboardingHero";
|
import { getStepNumber, TOTAL_STEPS } from "./OnboardingHero";
|
||||||
|
import { useOnboarding } from "./OnboardingContext";
|
||||||
|
|
||||||
interface NodeCheckStepProps {
|
interface NodeCheckStepProps {
|
||||||
nodeStatus: { installed: boolean; version: string | null } | null;
|
|
||||||
checking: boolean;
|
|
||||||
onCheck: () => void;
|
|
||||||
onInstall: (commandKey: TerminalCommandKey) => void;
|
|
||||||
onContinue: () => void;
|
onContinue: () => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
t: (key: string) => string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const INSTALL_COMMANDS: Record<string, { label: string; code: string; commandKey: TerminalCommandKey }> = {
|
const INSTALL_COMMANDS: Record<string, { label: string; code: string; commandKey: TerminalCommandKey }> = {
|
||||||
@@ -48,15 +44,8 @@ node -v`,
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function NodeCheckStep({
|
export function NodeCheckStep({ onContinue, onBack }: NodeCheckStepProps) {
|
||||||
nodeStatus,
|
const { nodeStatus, nodeChecking, onCheckNode, onInstallCommand, t } = useOnboarding();
|
||||||
checking,
|
|
||||||
onCheck,
|
|
||||||
onInstall,
|
|
||||||
onContinue,
|
|
||||||
onBack,
|
|
||||||
t,
|
|
||||||
}: NodeCheckStepProps) {
|
|
||||||
const platform = window.agentCompany.platform;
|
const platform = window.agentCompany.platform;
|
||||||
const platformInfo = INSTALL_COMMANDS[platform] ?? INSTALL_COMMANDS.linux;
|
const platformInfo = INSTALL_COMMANDS[platform] ?? INSTALL_COMMANDS.linux;
|
||||||
|
|
||||||
@@ -79,7 +68,7 @@ export function NodeCheckStep({
|
|||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<div className="mb-5 flex items-center gap-2.5 text-[13px]">
|
<div className="mb-5 flex items-center gap-2.5 text-[13px]">
|
||||||
{checking ? (
|
{nodeChecking ? (
|
||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-primary" />
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-primary" />
|
||||||
) : nodeStatus?.installed ? (
|
) : nodeStatus?.installed ? (
|
||||||
<CheckCircle size={18} weight="fill" className="shrink-0 text-success" />
|
<CheckCircle size={18} weight="fill" className="shrink-0 text-success" />
|
||||||
@@ -87,7 +76,7 @@ export function NodeCheckStep({
|
|||||||
<XCircle size={18} weight="fill" className="shrink-0 text-destructive" />
|
<XCircle size={18} weight="fill" className="shrink-0 text-destructive" />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{checking ? (
|
{nodeChecking ? (
|
||||||
<span className="text-muted-foreground">{t("onboarding.providerChecking")}...</span>
|
<span className="text-muted-foreground">{t("onboarding.providerChecking")}...</span>
|
||||||
) : nodeStatus?.installed ? (
|
) : nodeStatus?.installed ? (
|
||||||
<span className="font-medium text-success">
|
<span className="font-medium text-success">
|
||||||
@@ -111,7 +100,7 @@ export function NodeCheckStep({
|
|||||||
</pre>
|
</pre>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
label={t("onboarding.installNodeTerminal")}
|
label={t("onboarding.installNodeTerminal")}
|
||||||
onClick={() => onInstall(platformInfo.commandKey)}
|
onClick={() => onInstallCommand(platformInfo.commandKey)}
|
||||||
icon={Terminal}
|
icon={Terminal}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,7 +110,7 @@ export function NodeCheckStep({
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
label={t("onboarding.checkAgain")}
|
label={t("onboarding.checkAgain")}
|
||||||
onClick={onCheck}
|
onClick={onCheckNode}
|
||||||
icon={ArrowCounterClockwise}
|
icon={ArrowCounterClockwise}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { createContext, useContext, type ReactNode } from "react";
|
||||||
|
import type { AppLocale } from "@shared/locale";
|
||||||
|
import type {
|
||||||
|
ConnectorId,
|
||||||
|
ExecutionMode,
|
||||||
|
OnboardingBootstrapResult,
|
||||||
|
ProfileSnapshot,
|
||||||
|
} from "@shared/types";
|
||||||
|
import type { ConnectorModelEntry } from "@shared/connector-models";
|
||||||
|
import type { TerminalCommandKey } from "@shared/contracts";
|
||||||
|
import type { TranslationFn } from "../../i18n";
|
||||||
|
|
||||||
|
export interface OnboardingContextValue {
|
||||||
|
currentLocale: AppLocale;
|
||||||
|
t: TranslationFn;
|
||||||
|
|
||||||
|
error: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
|
||||||
|
nodeStatus: { installed: boolean; version: string | null } | null;
|
||||||
|
nodeChecking: boolean;
|
||||||
|
onCheckNode: () => void;
|
||||||
|
onInstallCommand: (commandKey: TerminalCommandKey) => void;
|
||||||
|
|
||||||
|
connectors: ProfileSnapshot["connectors"];
|
||||||
|
hasAtLeastOneReady: boolean;
|
||||||
|
onTestConnector: (connectorId: string) => void;
|
||||||
|
onSaveConnector: (
|
||||||
|
connectorId: string,
|
||||||
|
envBindingText: string,
|
||||||
|
model: string | null,
|
||||||
|
notes?: string,
|
||||||
|
) => void;
|
||||||
|
onLoginConnector: (connectorId: string) => void;
|
||||||
|
|
||||||
|
companyName: string;
|
||||||
|
setCompanyName: (value: string) => void;
|
||||||
|
companyDescription: string;
|
||||||
|
setCompanyDescription: (value: string) => void;
|
||||||
|
|
||||||
|
selectedConnectorId: ConnectorId | "";
|
||||||
|
setSelectedConnectorId: (value: ConnectorId | "") => void;
|
||||||
|
selectedModel: string;
|
||||||
|
setSelectedModel: (value: string) => void;
|
||||||
|
selectedConnector: ProfileSnapshot["connectors"][number] | null;
|
||||||
|
|
||||||
|
executionReadyConnectors: ProfileSnapshot["connectors"];
|
||||||
|
connectorModelPools: Array<{
|
||||||
|
connector: ProfileSnapshot["connectors"][number];
|
||||||
|
models: ConnectorModelEntry[];
|
||||||
|
}>;
|
||||||
|
modelOptions: Array<{ id: string; label: string }>;
|
||||||
|
loadingModels: boolean;
|
||||||
|
totalEnabledModels: number;
|
||||||
|
selectedConnectorEnabledModels: number;
|
||||||
|
onToggleConnectorModel: (connectorId: ConnectorId, modelId: string) => void;
|
||||||
|
|
||||||
|
workspacePath: string;
|
||||||
|
setWorkspacePath: (value: string) => void;
|
||||||
|
onPickDirectory: () => void;
|
||||||
|
|
||||||
|
executionMode: ExecutionMode;
|
||||||
|
setExecutionMode: (value: ExecutionMode) => void;
|
||||||
|
|
||||||
|
goalTitle: string;
|
||||||
|
setGoalTitle: (value: string) => void;
|
||||||
|
goalDescription: string;
|
||||||
|
setGoalDescription: (value: string) => void;
|
||||||
|
|
||||||
|
launchSummary: OnboardingBootstrapResult | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OnboardingContext = createContext<OnboardingContextValue | null>(null);
|
||||||
|
|
||||||
|
export function OnboardingProvider({
|
||||||
|
value,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
value: OnboardingContextValue;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return <OnboardingContext.Provider value={value}>{children}</OnboardingContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOnboarding(): OnboardingContextValue {
|
||||||
|
const ctx = useContext(OnboardingContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useOnboarding must be used within an OnboardingProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -1,9 +1,4 @@
|
|||||||
import type { AppLocale } from "@shared/locale";
|
|
||||||
import type { ConnectorId, OnboardingBootstrapResult, ProfileSnapshot } from "@shared/types";
|
|
||||||
import type { ConnectorModelEntry } from "@shared/connector-models";
|
|
||||||
import type { TerminalCommandKey } from "@shared/contracts";
|
|
||||||
import { AnimatePresence } from "framer-motion";
|
import { AnimatePresence } from "framer-motion";
|
||||||
import type { TranslationFn } from "../../i18n";
|
|
||||||
import { OnboardingHero, type OnboardingStep } from "./OnboardingHero";
|
import { OnboardingHero, type OnboardingStep } from "./OnboardingHero";
|
||||||
import { NodeCheckStep } from "./NodeCheckStep";
|
import { NodeCheckStep } from "./NodeCheckStep";
|
||||||
import { ProvidersStep } from "./ProvidersStep";
|
import { ProvidersStep } from "./ProvidersStep";
|
||||||
@@ -12,54 +7,13 @@ import { ModelPoolStep } from "./ModelPoolStep";
|
|||||||
import { ExecutionModeStep } from "./ExecutionModeStep";
|
import { ExecutionModeStep } from "./ExecutionModeStep";
|
||||||
import { MissionStep } from "./MissionStep";
|
import { MissionStep } from "./MissionStep";
|
||||||
import { LaunchStep } from "./LaunchStep";
|
import { LaunchStep } from "./LaunchStep";
|
||||||
|
import { useOnboarding } from "./OnboardingContext";
|
||||||
|
|
||||||
interface OnboardingStepPanelProps {
|
interface OnboardingStepPanelProps {
|
||||||
step: OnboardingStep;
|
step: OnboardingStep;
|
||||||
currentLocale: AppLocale;
|
|
||||||
t: TranslationFn;
|
|
||||||
canCancel: boolean;
|
canCancel: boolean;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
isFirstLaunch: boolean;
|
isFirstLaunch: boolean;
|
||||||
nodeStatus: { installed: boolean; version: string | null } | null;
|
|
||||||
nodeChecking: boolean;
|
|
||||||
onCheckNode: () => void;
|
|
||||||
onInstallCommand: (commandKey: TerminalCommandKey) => void;
|
|
||||||
connectors: ProfileSnapshot["connectors"];
|
|
||||||
hasAtLeastOneReady: boolean;
|
|
||||||
onTestConnector: (connectorId: string) => void;
|
|
||||||
onSaveConnector: (connectorId: string, envBindingText: string, model: string | null, notes?: string) => void;
|
|
||||||
onLoginConnector: (connectorId: string) => void;
|
|
||||||
error: string | null;
|
|
||||||
companyName: string;
|
|
||||||
setCompanyName: (value: string) => void;
|
|
||||||
companyDescription: string;
|
|
||||||
setCompanyDescription: (value: string) => void;
|
|
||||||
selectedConnectorId: ConnectorId | "";
|
|
||||||
setSelectedConnectorId: (value: ConnectorId | "") => void;
|
|
||||||
selectedModel: string;
|
|
||||||
setSelectedModel: (value: string) => void;
|
|
||||||
executionReadyConnectors: ProfileSnapshot["connectors"];
|
|
||||||
connectorModelPools: Array<{
|
|
||||||
connector: ProfileSnapshot["connectors"][number];
|
|
||||||
models: ConnectorModelEntry[];
|
|
||||||
}>;
|
|
||||||
modelOptions: Array<{ id: string; label: string }>;
|
|
||||||
loadingModels: boolean;
|
|
||||||
totalEnabledModels: number;
|
|
||||||
selectedConnectorEnabledModels: number;
|
|
||||||
onToggleConnectorModel: (connectorId: ConnectorId, modelId: string) => void;
|
|
||||||
workspacePath: string;
|
|
||||||
setWorkspacePath: (value: string) => void;
|
|
||||||
onPickDirectory: () => void;
|
|
||||||
executionMode: "ai_ceo" | "ai_cofounder";
|
|
||||||
setExecutionMode: (value: "ai_ceo" | "ai_cofounder") => void;
|
|
||||||
goalTitle: string;
|
|
||||||
setGoalTitle: (value: string) => void;
|
|
||||||
goalDescription: string;
|
|
||||||
setGoalDescription: (value: string) => void;
|
|
||||||
loading: boolean;
|
|
||||||
launchSummary: OnboardingBootstrapResult | null;
|
|
||||||
selectedConnector: ProfileSnapshot["connectors"][number] | null;
|
|
||||||
onBack: (step: OnboardingStep) => void;
|
onBack: (step: OnboardingStep) => void;
|
||||||
onContinueFromNodeCheck: () => void;
|
onContinueFromNodeCheck: () => void;
|
||||||
onContinueFromProviders: () => void;
|
onContinueFromProviders: () => void;
|
||||||
@@ -72,48 +26,9 @@ interface OnboardingStepPanelProps {
|
|||||||
|
|
||||||
export function OnboardingStepPanel({
|
export function OnboardingStepPanel({
|
||||||
step,
|
step,
|
||||||
currentLocale,
|
|
||||||
t,
|
|
||||||
canCancel,
|
canCancel,
|
||||||
onCancel,
|
onCancel,
|
||||||
isFirstLaunch,
|
isFirstLaunch,
|
||||||
nodeStatus,
|
|
||||||
nodeChecking,
|
|
||||||
onCheckNode,
|
|
||||||
onInstallCommand,
|
|
||||||
connectors,
|
|
||||||
hasAtLeastOneReady,
|
|
||||||
onTestConnector,
|
|
||||||
onSaveConnector,
|
|
||||||
onLoginConnector,
|
|
||||||
error,
|
|
||||||
companyName,
|
|
||||||
setCompanyName,
|
|
||||||
companyDescription,
|
|
||||||
setCompanyDescription,
|
|
||||||
selectedConnectorId,
|
|
||||||
setSelectedConnectorId,
|
|
||||||
selectedModel,
|
|
||||||
setSelectedModel,
|
|
||||||
executionReadyConnectors,
|
|
||||||
connectorModelPools,
|
|
||||||
modelOptions,
|
|
||||||
loadingModels,
|
|
||||||
totalEnabledModels,
|
|
||||||
selectedConnectorEnabledModels,
|
|
||||||
onToggleConnectorModel,
|
|
||||||
workspacePath,
|
|
||||||
setWorkspacePath,
|
|
||||||
onPickDirectory,
|
|
||||||
executionMode,
|
|
||||||
setExecutionMode,
|
|
||||||
goalTitle,
|
|
||||||
setGoalTitle,
|
|
||||||
goalDescription,
|
|
||||||
setGoalDescription,
|
|
||||||
loading,
|
|
||||||
launchSummary,
|
|
||||||
selectedConnector,
|
|
||||||
onBack,
|
onBack,
|
||||||
onContinueFromNodeCheck,
|
onContinueFromNodeCheck,
|
||||||
onContinueFromProviders,
|
onContinueFromProviders,
|
||||||
@@ -123,6 +38,8 @@ export function OnboardingStepPanel({
|
|||||||
onLaunchCompany,
|
onLaunchCompany,
|
||||||
onFinishLaunch,
|
onFinishLaunch,
|
||||||
}: OnboardingStepPanelProps) {
|
}: OnboardingStepPanelProps) {
|
||||||
|
const { currentLocale, t, launchSummary } = useOnboarding();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<OnboardingHero
|
<OnboardingHero
|
||||||
@@ -146,103 +63,48 @@ export function OnboardingStepPanel({
|
|||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{step === "nodecheck" ? (
|
{step === "nodecheck" ? (
|
||||||
<NodeCheckStep
|
<NodeCheckStep
|
||||||
nodeStatus={nodeStatus}
|
|
||||||
checking={nodeChecking}
|
|
||||||
onCheck={onCheckNode}
|
|
||||||
onInstall={(commandKey) => onInstallCommand(commandKey)}
|
|
||||||
onContinue={onContinueFromNodeCheck}
|
onContinue={onContinueFromNodeCheck}
|
||||||
onBack={() => onBack("welcome")}
|
onBack={() => onBack("welcome")}
|
||||||
t={t}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step === "providers" ? (
|
{step === "providers" ? (
|
||||||
<ProvidersStep
|
<ProvidersStep
|
||||||
connectors={connectors}
|
|
||||||
onTestConnector={onTestConnector}
|
|
||||||
onSaveConnector={onSaveConnector}
|
|
||||||
onLoginConnector={onLoginConnector}
|
|
||||||
onInstallCommand={onInstallCommand}
|
|
||||||
hasAtLeastOneReady={hasAtLeastOneReady}
|
|
||||||
onContinue={onContinueFromProviders}
|
onContinue={onContinueFromProviders}
|
||||||
onBack={() => onBack("nodecheck")}
|
onBack={() => onBack("nodecheck")}
|
||||||
error={error}
|
|
||||||
t={t}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step === "company" ? (
|
{step === "company" ? (
|
||||||
<CompanyStep
|
<CompanyStep
|
||||||
companyName={companyName}
|
|
||||||
setCompanyName={setCompanyName}
|
|
||||||
companyDescription={companyDescription}
|
|
||||||
setCompanyDescription={setCompanyDescription}
|
|
||||||
selectedConnectorId={selectedConnectorId}
|
|
||||||
setSelectedConnectorId={setSelectedConnectorId}
|
|
||||||
selectedModel={selectedModel}
|
|
||||||
setSelectedModel={setSelectedModel}
|
|
||||||
executionReadyConnectors={executionReadyConnectors}
|
|
||||||
modelOptions={modelOptions}
|
|
||||||
loadingModels={loadingModels}
|
|
||||||
workspacePath={workspacePath}
|
|
||||||
setWorkspacePath={setWorkspacePath}
|
|
||||||
onPickDirectory={onPickDirectory}
|
|
||||||
onContinue={onContinueFromCompany}
|
onContinue={onContinueFromCompany}
|
||||||
onBack={() => onBack("providers")}
|
onBack={() => onBack("providers")}
|
||||||
error={error}
|
|
||||||
currentLocale={currentLocale}
|
|
||||||
t={t}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step === "modelpool" ? (
|
{step === "modelpool" ? (
|
||||||
<ModelPoolStep
|
<ModelPoolStep
|
||||||
currentLocale={currentLocale}
|
|
||||||
connectorModelPools={connectorModelPools}
|
|
||||||
error={error}
|
|
||||||
onBack={() => onBack("company")}
|
onBack={() => onBack("company")}
|
||||||
onContinue={onContinueFromModelPool}
|
onContinue={onContinueFromModelPool}
|
||||||
onToggleConnectorModel={onToggleConnectorModel}
|
|
||||||
selectedConnectorId={selectedConnectorId}
|
|
||||||
selectedConnectorEnabledModels={selectedConnectorEnabledModels}
|
|
||||||
totalEnabledModels={totalEnabledModels}
|
|
||||||
t={t}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step === "executionmode" ? (
|
{step === "executionmode" ? (
|
||||||
<ExecutionModeStep
|
<ExecutionModeStep
|
||||||
executionMode={executionMode}
|
|
||||||
setExecutionMode={setExecutionMode}
|
|
||||||
onContinue={onContinueFromExecutionMode}
|
onContinue={onContinueFromExecutionMode}
|
||||||
onBack={() => onBack("modelpool")}
|
onBack={() => onBack("modelpool")}
|
||||||
t={t}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step === "mission" ? (
|
{step === "mission" ? (
|
||||||
<MissionStep
|
<MissionStep
|
||||||
goalTitle={goalTitle}
|
|
||||||
setGoalTitle={setGoalTitle}
|
|
||||||
goalDescription={goalDescription}
|
|
||||||
setGoalDescription={setGoalDescription}
|
|
||||||
onLaunch={onLaunchCompany}
|
onLaunch={onLaunchCompany}
|
||||||
onBack={() => onBack("executionmode")}
|
onBack={() => onBack("executionmode")}
|
||||||
loading={loading}
|
|
||||||
error={error}
|
|
||||||
t={t}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step === "launch" && launchSummary ? (
|
{step === "launch" && launchSummary ? (
|
||||||
<LaunchStep
|
<LaunchStep onFinish={onFinishLaunch} />
|
||||||
launchSummary={launchSummary}
|
|
||||||
selectedConnector={selectedConnector}
|
|
||||||
onFinish={onFinishLaunch}
|
|
||||||
loading={loading}
|
|
||||||
error={error}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import type { ConnectorId, ExecutionMode, OnboardingBootstrapResult, ProfileSnapshot } from "@shared/types";
|
import type { ConnectorId, ExecutionMode, OnboardingBootstrapResult, ProfileSnapshot } from "@shared/types";
|
||||||
import type { AppLocale } from "@shared/locale";
|
import type { AppLocale } from "@shared/locale";
|
||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
@@ -8,6 +8,7 @@ import { WelcomeStep } from "./WelcomeStep";
|
|||||||
import { OnboardingStepPanel } from "./OnboardingStepPanel";
|
import { OnboardingStepPanel } from "./OnboardingStepPanel";
|
||||||
import { useConnectorCallbacks } from "./useConnectorCallbacks";
|
import { useConnectorCallbacks } from "./useConnectorCallbacks";
|
||||||
import { useConnectorModelSelection } from "./useConnectorModelSelection";
|
import { useConnectorModelSelection } from "./useConnectorModelSelection";
|
||||||
|
import { OnboardingProvider, type OnboardingContextValue } from "./OnboardingContext";
|
||||||
|
|
||||||
export function CompanyOnboarding({
|
export function CompanyOnboarding({
|
||||||
connectors,
|
connectors,
|
||||||
@@ -159,6 +160,85 @@ export function CompanyOnboarding({
|
|||||||
const isWelcome = step === "welcome";
|
const isWelcome = step === "welcome";
|
||||||
const heroStep: OnboardingStep = step as OnboardingStep;
|
const heroStep: OnboardingStep = step as OnboardingStep;
|
||||||
|
|
||||||
|
const contextValue = useMemo<OnboardingContextValue>(
|
||||||
|
() => ({
|
||||||
|
currentLocale,
|
||||||
|
t,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
nodeStatus,
|
||||||
|
nodeChecking,
|
||||||
|
onCheckNode: () => void checkNode(),
|
||||||
|
onInstallCommand: (commandKey) => void handleInstallCommand(commandKey),
|
||||||
|
connectors,
|
||||||
|
hasAtLeastOneReady,
|
||||||
|
onTestConnector: (id) => void handleTestConnector(id),
|
||||||
|
onSaveConnector: (id, env, model, notes) => void handleSaveConnector(id, env, model, notes),
|
||||||
|
onLoginConnector: (id) => void handleLoginConnector(id),
|
||||||
|
companyName,
|
||||||
|
setCompanyName,
|
||||||
|
companyDescription,
|
||||||
|
setCompanyDescription,
|
||||||
|
selectedConnectorId,
|
||||||
|
setSelectedConnectorId,
|
||||||
|
selectedModel,
|
||||||
|
setSelectedModel,
|
||||||
|
selectedConnector,
|
||||||
|
executionReadyConnectors,
|
||||||
|
connectorModelPools,
|
||||||
|
modelOptions,
|
||||||
|
loadingModels,
|
||||||
|
totalEnabledModels,
|
||||||
|
selectedConnectorEnabledModels,
|
||||||
|
onToggleConnectorModel: (connectorId, modelId) => void toggleConnectorModel(connectorId, modelId),
|
||||||
|
workspacePath,
|
||||||
|
setWorkspacePath,
|
||||||
|
onPickDirectory: () => void pickDirectory(),
|
||||||
|
executionMode,
|
||||||
|
setExecutionMode,
|
||||||
|
goalTitle,
|
||||||
|
setGoalTitle,
|
||||||
|
goalDescription,
|
||||||
|
setGoalDescription,
|
||||||
|
launchSummary,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
currentLocale,
|
||||||
|
t,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
nodeStatus,
|
||||||
|
nodeChecking,
|
||||||
|
checkNode,
|
||||||
|
handleInstallCommand,
|
||||||
|
connectors,
|
||||||
|
hasAtLeastOneReady,
|
||||||
|
handleTestConnector,
|
||||||
|
handleSaveConnector,
|
||||||
|
handleLoginConnector,
|
||||||
|
companyName,
|
||||||
|
companyDescription,
|
||||||
|
selectedConnectorId,
|
||||||
|
setSelectedConnectorId,
|
||||||
|
selectedModel,
|
||||||
|
setSelectedModel,
|
||||||
|
selectedConnector,
|
||||||
|
executionReadyConnectors,
|
||||||
|
connectorModelPools,
|
||||||
|
modelOptions,
|
||||||
|
loadingModels,
|
||||||
|
totalEnabledModels,
|
||||||
|
selectedConnectorEnabledModels,
|
||||||
|
toggleConnectorModel,
|
||||||
|
workspacePath,
|
||||||
|
pickDirectory,
|
||||||
|
executionMode,
|
||||||
|
goalTitle,
|
||||||
|
goalDescription,
|
||||||
|
launchSummary,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative h-[100dvh] overflow-hidden bg-background ${
|
className={`relative h-[100dvh] overflow-hidden bg-background ${
|
||||||
@@ -178,51 +258,12 @@ export function CompanyOnboarding({
|
|||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<OnboardingProvider value={contextValue}>
|
||||||
<OnboardingStepPanel
|
<OnboardingStepPanel
|
||||||
step={heroStep}
|
step={heroStep}
|
||||||
currentLocale={currentLocale}
|
|
||||||
t={t}
|
|
||||||
canCancel={canCancel}
|
canCancel={canCancel}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
isFirstLaunch={isFirstLaunch}
|
isFirstLaunch={isFirstLaunch}
|
||||||
nodeStatus={nodeStatus}
|
|
||||||
nodeChecking={nodeChecking}
|
|
||||||
onCheckNode={() => void checkNode()}
|
|
||||||
onInstallCommand={(commandKey) => void handleInstallCommand(commandKey)}
|
|
||||||
connectors={connectors}
|
|
||||||
hasAtLeastOneReady={hasAtLeastOneReady}
|
|
||||||
onTestConnector={(id) => void handleTestConnector(id)}
|
|
||||||
onSaveConnector={(id, env, model, notes) => void handleSaveConnector(id, env, model, notes)}
|
|
||||||
onLoginConnector={(id) => void handleLoginConnector(id)}
|
|
||||||
error={error}
|
|
||||||
companyName={companyName}
|
|
||||||
setCompanyName={setCompanyName}
|
|
||||||
companyDescription={companyDescription}
|
|
||||||
setCompanyDescription={setCompanyDescription}
|
|
||||||
selectedConnectorId={selectedConnectorId}
|
|
||||||
setSelectedConnectorId={setSelectedConnectorId}
|
|
||||||
selectedModel={selectedModel}
|
|
||||||
setSelectedModel={setSelectedModel}
|
|
||||||
executionReadyConnectors={executionReadyConnectors}
|
|
||||||
connectorModelPools={connectorModelPools}
|
|
||||||
modelOptions={modelOptions}
|
|
||||||
loadingModels={loadingModels}
|
|
||||||
totalEnabledModels={totalEnabledModels}
|
|
||||||
selectedConnectorEnabledModels={selectedConnectorEnabledModels}
|
|
||||||
onToggleConnectorModel={(connectorId, modelId) => void toggleConnectorModel(connectorId, modelId)}
|
|
||||||
workspacePath={workspacePath}
|
|
||||||
setWorkspacePath={setWorkspacePath}
|
|
||||||
onPickDirectory={() => void pickDirectory()}
|
|
||||||
executionMode={executionMode}
|
|
||||||
setExecutionMode={setExecutionMode}
|
|
||||||
goalTitle={goalTitle}
|
|
||||||
setGoalTitle={setGoalTitle}
|
|
||||||
goalDescription={goalDescription}
|
|
||||||
setGoalDescription={setGoalDescription}
|
|
||||||
loading={loading}
|
|
||||||
launchSummary={launchSummary}
|
|
||||||
selectedConnector={selectedConnector}
|
|
||||||
onBack={setStep}
|
onBack={setStep}
|
||||||
onContinueFromNodeCheck={() => { setError(null); setStep("providers"); }}
|
onContinueFromNodeCheck={() => { setError(null); setStep("providers"); }}
|
||||||
onContinueFromProviders={() => { setError(null); setStep("company"); }}
|
onContinueFromProviders={() => { setError(null); setStep("company"); }}
|
||||||
@@ -232,7 +273,7 @@ export function CompanyOnboarding({
|
|||||||
onLaunchCompany={() => void launchCompany()}
|
onLaunchCompany={() => void launchCompany()}
|
||||||
onFinishLaunch={() => void finishLaunch()}
|
onFinishLaunch={() => void finishLaunch()}
|
||||||
/>
|
/>
|
||||||
</>
|
</OnboardingProvider>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { ArrowRight, FloppyDisk, SignIn } from "@phosphor-icons/react";
|
import { ArrowRight, FloppyDisk, SignIn } from "@phosphor-icons/react";
|
||||||
import type { ProfileSnapshot } from "@shared/types";
|
|
||||||
import type { TerminalCommandKey } from "@shared/contracts";
|
|
||||||
import { ActionButton } from "../ui";
|
import { ActionButton } from "../ui";
|
||||||
import {
|
import {
|
||||||
CompatProviderCard,
|
CompatProviderCard,
|
||||||
@@ -12,22 +10,15 @@ import {
|
|||||||
INPUT_CLS,
|
INPUT_CLS,
|
||||||
} from "./ProvidersStepShared";
|
} from "./ProvidersStepShared";
|
||||||
import { getStepNumber, TOTAL_STEPS } from "./OnboardingHero";
|
import { getStepNumber, TOTAL_STEPS } from "./OnboardingHero";
|
||||||
|
import { useOnboarding } from "./OnboardingContext";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Props */
|
/* Props */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
interface ProvidersStepProps {
|
interface ProvidersStepProps {
|
||||||
connectors: ProfileSnapshot["connectors"];
|
|
||||||
onTestConnector: (connectorId: string) => void;
|
|
||||||
onSaveConnector: (connectorId: string, envBindingText: string, model: string | null, notes?: string) => void;
|
|
||||||
onLoginConnector: (connectorId: string) => void;
|
|
||||||
onInstallCommand: (commandKey: TerminalCommandKey) => void;
|
|
||||||
hasAtLeastOneReady: boolean;
|
|
||||||
onContinue: () => void;
|
onContinue: () => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
error: string | null;
|
|
||||||
t: (key: string) => string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMPAT_SLOTS = ["claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5", "claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10"] as const;
|
const COMPAT_SLOTS = ["claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5", "claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10"] as const;
|
||||||
@@ -36,18 +27,18 @@ const COMPAT_SLOTS = ["claude_compatible", "claude_compat_2", "claude_compat_3",
|
|||||||
/* Main component */
|
/* Main component */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export function ProvidersStep({
|
export function ProvidersStep({ onContinue, onBack }: ProvidersStepProps) {
|
||||||
connectors,
|
const {
|
||||||
onTestConnector,
|
connectors,
|
||||||
onSaveConnector,
|
onTestConnector,
|
||||||
onLoginConnector,
|
onSaveConnector,
|
||||||
onInstallCommand,
|
onLoginConnector,
|
||||||
hasAtLeastOneReady,
|
onInstallCommand,
|
||||||
onContinue,
|
hasAtLeastOneReady,
|
||||||
onBack,
|
error,
|
||||||
error,
|
t,
|
||||||
t,
|
} = useOnboarding();
|
||||||
}: ProvidersStepProps) {
|
|
||||||
const [expandedCard, setExpandedCard] = useState<string | null>("claude");
|
const [expandedCard, setExpandedCard] = useState<string | null>("claude");
|
||||||
|
|
||||||
const claudeConnector = connectors.find((c) => c.id === "claude_local");
|
const claudeConnector = connectors.find((c) => c.id === "claude_local");
|
||||||
|
|||||||
Reference in new issue
Block a user