From 23a80462f0b8429e8c04ce1d10e93af4611059c6 Mon Sep 17 00:00:00 2001 From: Manik Date: Thu, 2 Apr 2026 14:54:54 +0530 Subject: [PATCH] feat: onboarding personality + name step with dynamic email domain - Two-step onboarding at /onboarding/name: personality selection (5 presets + custom) then name/email form - Auto-generates butler email as {butlerName}_{userName}@{domain} for default personalities - Removes hardcoded getcore.me; derives email domain from LOGIN_ORIGIN env var - Custom personality option prefills prompt with Alfred voice guide; saves to workspace on submit - Saves personality selection to user.metadata on completion - Exports ALFRED_VOICE from personality.ts for reuse - Adds utils/onboarding-email.ts (deriveEmailDomain, generateButlerEmailSlug, generateButlerEmail) with vitest tests Co-Authored-By: Claude Sonnet 4.6 --- .../onboarding/onboarding-agent-name.tsx | 285 ++++++++++++++---- apps/webapp/app/routes/onboarding.name.tsx | 71 ++++- .../app/services/agent/prompts/personality.ts | 2 +- apps/webapp/app/services/channel.server.ts | 10 +- .../utils/__tests__/onboarding-email.test.ts | 70 +++++ apps/webapp/app/utils/onboarding-email.ts | 39 +++ 6 files changed, 411 insertions(+), 66 deletions(-) create mode 100644 apps/webapp/app/utils/__tests__/onboarding-email.test.ts create mode 100644 apps/webapp/app/utils/onboarding-email.ts diff --git a/apps/webapp/app/components/onboarding/onboarding-agent-name.tsx b/apps/webapp/app/components/onboarding/onboarding-agent-name.tsx index 8df5da61d..fad300f97 100644 --- a/apps/webapp/app/components/onboarding/onboarding-agent-name.tsx +++ b/apps/webapp/app/components/onboarding/onboarding-agent-name.tsx @@ -1,41 +1,77 @@ import { useState, useEffect, useRef, useCallback } from "react"; -import { Loader2, Check, X } from "lucide-react"; +import { Loader2, Check, X, Plus, ArrowLeft } from "lucide-react"; import { Button } from "~/components/ui"; import { Input } from "~/components/ui/input"; +import { Textarea } from "~/components/ui/textarea"; +import { Card } from "~/components/ui/card"; +import { cn } from "~/lib/utils"; +import { + PERSONALITY_OPTIONS, + type PersonalityType, +} from "~/services/agent/prompts/personality"; +import { ALFRED_VOICE } from "~/services/agent/prompts/personality"; +import { + generateButlerEmailSlug, + generateButlerEmail, +} from "~/utils/onboarding-email"; -interface OnboardingAgentNameProps { +type Step = "personality" | "form"; +type AvailabilityState = "idle" | "checking" | "available" | "taken"; + +export interface CustomPersonalityData { + name: string; + text: string; + useHonorifics: boolean; +} + +export interface OnboardingAgentNameProps { defaultName: string; defaultSlug: string; workspaceId: string; - onComplete: (name: string, slug: string) => void; + emailDomain: string; + userName: string; + onComplete: ( + name: string, + slug: string, + personalityId: string, + customPersonality?: CustomPersonalityData, + ) => void; isSubmitting?: boolean; } -type AvailabilityState = "idle" | "checking" | "available" | "taken"; - -function slugify(value: string): string { - return value - .toLowerCase() - .trim() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); -} - export function OnboardingAgentName({ defaultName, defaultSlug, workspaceId, + emailDomain, + userName, onComplete, isSubmitting = false, }: OnboardingAgentNameProps) { - const [name, setName] = useState(defaultName); - const [slug, setSlug] = useState(defaultSlug); - const [slugManuallyEdited, setSlugManuallyEdited] = useState(false); + const [step, setStep] = useState("personality"); + const [selectedPersonalityId, setSelectedPersonalityId] = useState< + PersonalityType | "custom" | null + >(null); + + // Step 2 form state + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [emailManuallyEdited, setEmailManuallyEdited] = useState(false); + const [customPrompt, setCustomPrompt] = useState(ALFRED_VOICE); const [availability, setAvailability] = useState("idle"); const debounceRef = useRef | null>(null); + const isCustom = selectedPersonalityId === "custom"; + + // Derive slug from email local part (before @) + const slugFromEmail = email.split("@")[0] ?? ""; + const checkAvailability = useCallback( async (slugValue: string) => { + if (!slugValue.trim()) { + setAvailability("idle"); + return; + } setAvailability("checking"); try { const res = await fetch("/api/v1/onboarding/check-name", { @@ -55,38 +91,155 @@ export function OnboardingAgentName({ [workspaceId], ); - // Auto-derive slug from name unless user has manually edited it + // Auto-generate email from name when not manually edited (default personalities only) useEffect(() => { - if (!slugManuallyEdited) { - setSlug(slugify(name)); + if (emailManuallyEdited || isCustom) return; + if (!name.trim()) { + setEmail(""); + return; } - }, [name, slugManuallyEdited]); + setEmail(generateButlerEmail(name, userName, emailDomain)); + }, [name, userName, emailDomain, emailManuallyEdited, isCustom]); // Debounced availability check on slug useEffect(() => { - if (!slug.trim()) { + if (isCustom || !slugFromEmail.trim()) { setAvailability("idle"); return; } if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => checkAvailability(slug), 500); + debounceRef.current = setTimeout( + () => checkAvailability(slugFromEmail), + 500, + ); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; - }, [slug, checkAvailability]); + }, [slugFromEmail, checkAvailability, isCustom]); + + const handlePersonalitySelect = (id: PersonalityType | "custom") => { + setSelectedPersonalityId(id); + setEmailManuallyEdited(false); + setAvailability("idle"); + + if (id === "custom") { + setName(""); + setEmail(""); + setCustomPrompt(ALFRED_VOICE); + } else { + const option = PERSONALITY_OPTIONS.find((p) => p.id === id); + setName(option?.name ?? ""); + // email auto-generates via useEffect above + } + + setStep("form"); + }; + + const handleEmailChange = (value: string) => { + setEmailManuallyEdited(true); + setEmail(value); + }; + + const handleBack = () => { + setStep("personality"); + setAvailability("idle"); + }; const canContinue = - name.trim() && - slug && - availability !== "taken" && - availability !== "checking"; + !!name.trim() && + !isSubmitting && + (isCustom + ? !!customPrompt.trim() + : availability === "available" && !!slugFromEmail.trim()); + + const handleContinue = () => { + if (!canContinue || !selectedPersonalityId) return; + const finalSlug = + slugFromEmail || generateButlerEmailSlug(name, userName); + + if (isCustom) { + const customId = name.toLowerCase().replace(/\s+/g, "-"); + onComplete(name.trim(), finalSlug, customId, { + name: name.trim(), + text: customPrompt, + useHonorifics: false, + }); + } else { + onComplete(name.trim(), finalSlug, selectedPersonalityId); + } + }; + // --- Step 1: Personality selection --- + if (step === "personality") { + return ( +
+
+

choose a personality

+

+ pick who shows up in your inbox. +

+
+ +
+ {PERSONALITY_OPTIONS.map((option) => ( + handlePersonalitySelect(option.id)} + > +

{option.name}

+

+ {option.description} +

+ {option.examples.slice(0, 1).map((example, idx) => ( +
+

+ "{example.prompt}" +

+

"{example.response}"

+
+ ))} +
+ ))} + + handlePersonalitySelect("custom")} + > + +

build your own

+
+
+
+ ); + } + + // --- Step 2: Name / Email form --- return (
+
+ +
+

name your butler

- give your butler a name. this is how you'll know them. + {isCustom + ? "set up your custom butler." + : "give your butler a name. this is how you'll know them."}

@@ -94,7 +247,7 @@ export function OnboardingAgentName({ setName(e.target.value)} - placeholder="e.g. Alfred" + placeholder={isCustom ? "e.g. Jarvis, Samantha, Max" : "e.g. Alfred"} className="h-10" disabled={isSubmitting} autoFocus @@ -103,45 +256,61 @@ export function OnboardingAgentName({
{ - setSlugManuallyEdited(true); - setSlug(slugify(e.target.value)); - }} - placeholder="email slug" + value={email} + onChange={(e) => handleEmailChange(e.target.value)} + placeholder={`butler_you@${emailDomain}`} className="h-10 pr-10" - disabled={isSubmitting} + disabled={isSubmitting || isCustom} /> -
- {availability === "checking" && ( - - )} - {availability === "available" && ( - - )} - {availability === "taken" && ( - - )} -
+ {!isCustom && ( +
+ {availability === "checking" && ( + + )} + {availability === "available" && ( + + )} + {availability === "taken" && ( + + )} +
+ )}
- {availability === "taken" ? ( -

- "{slug}@getcore.me" is already taken. -

- ) : slug ? ( -

- your butler's email:{" "} - {slug}@getcore.me -

- ) : null} + {!isCustom && ( + <> + {availability === "taken" ? ( +

+ "{email}" is already taken. +

+ ) : email ? ( +

+ your butler's email:{" "} + {email} +

+ ) : null} + + )}
+ + {isCustom && ( +
+

personality prompt

+