Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
285 changes: 227 additions & 58 deletions apps/webapp/app/components/onboarding/onboarding-agent-name.tsx
Original file line number Diff line number Diff line change
@@ -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<Step>("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<AvailabilityState>("idle");
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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", {
Expand All @@ -55,46 +91,163 @@ 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 (
<div className="flex w-full max-w-2xl flex-col gap-4 p-3">
<div className="space-y-1">
<h2 className="text-xl font-semibold">choose a personality</h2>
<p className="text-muted-foreground text-base">
pick who shows up in your inbox.
</p>
</div>

<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{PERSONALITY_OPTIONS.map((option) => (
<Card
key={option.id}
className={cn(
"hover:border-primary/50 relative cursor-pointer p-4 transition-all",
selectedPersonalityId === option.id &&
"border-primary/50 border",
)}
onClick={() => handlePersonalitySelect(option.id)}
>
<h3 className="mb-1 font-medium">{option.name}</h3>
<p className="text-muted-foreground mb-3 text-sm">
{option.description}
</p>
{option.examples.slice(0, 1).map((example, idx) => (
<div key={idx} className="bg-muted/50 rounded-md p-2 text-xs">
<p className="text-muted-foreground mb-1">
"{example.prompt}"
</p>
<p className="italic">"{example.response}"</p>
</div>
))}
</Card>
))}

<Card
className="hover:border-primary/50 flex cursor-pointer flex-col items-center justify-center gap-2 border-dashed p-4 transition-all"
onClick={() => handlePersonalitySelect("custom")}
>
<Plus className="text-muted-foreground h-5 w-5" />
<p className="text-muted-foreground text-sm">build your own</p>
</Card>
</div>
</div>
);
}

// --- Step 2: Name / Email form ---
return (
<div className="flex w-full max-w-lg flex-col gap-4 p-3">
<div>
<Button
variant="ghost"
size="sm"
onClick={handleBack}
disabled={isSubmitting}
className="gap-1.5 px-2"
>
<ArrowLeft className="h-4 w-4" />
change personality
</Button>
</div>

<div className="space-y-1">
<h2 className="text-xl font-semibold">name your butler</h2>
<p className="text-muted-foreground text-base">
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."}
</p>
</div>

<div className="space-y-3">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Alfred"
placeholder={isCustom ? "e.g. Jarvis, Samantha, Max" : "e.g. Alfred"}
className="h-10"
disabled={isSubmitting}
autoFocus
Expand All @@ -103,45 +256,61 @@ export function OnboardingAgentName({
<div className="space-y-1">
<div className="relative">
<Input
value={slug}
onChange={(e) => {
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}
/>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center">
{availability === "checking" && (
<Loader2 className="text-muted-foreground size-4 animate-spin" />
)}
{availability === "available" && (
<Check className="text-success size-4" />
)}
{availability === "taken" && (
<X className="text-destructive size-4" />
)}
</div>
{!isCustom && (
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center">
{availability === "checking" && (
<Loader2 className="text-muted-foreground size-4 animate-spin" />
)}
{availability === "available" && (
<Check className="text-success size-4" />
)}
{availability === "taken" && (
<X className="text-destructive size-4" />
)}
</div>
)}
</div>
{availability === "taken" ? (
<p className="text-destructive text-xs">
"{slug}@getcore.me" is already taken.
</p>
) : slug ? (
<p className="text-muted-foreground text-xs">
your butler's email:{" "}
<span className="text-foreground font-medium">{slug}@getcore.me</span>
</p>
) : null}
{!isCustom && (
<>
{availability === "taken" ? (
<p className="text-destructive text-xs">
"{email}" is already taken.
</p>
) : email ? (
<p className="text-muted-foreground text-xs">
your butler's email:{" "}
<span className="text-foreground font-medium">{email}</span>
</p>
) : null}
</>
)}
</div>

{isCustom && (
<div className="space-y-1">
<p className="text-muted-foreground text-xs">personality prompt</p>
<Textarea
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
placeholder="Describe how the butler should talk — tone, style, what they never say..."
className="min-h-[160px] text-sm"
disabled={isSubmitting}
/>
</div>
)}
</div>

<div className="flex justify-end">
<Button
variant="secondary"
size="lg"
onClick={() => canContinue && onComplete(name.trim(), slug)}
onClick={handleContinue}
disabled={!canContinue || isSubmitting}
isLoading={isSubmitting}
>
Expand Down
Loading