"use client"; import { ArrowLeftIcon, ArrowRightIcon, BotIcon, CalendarCheckIcon, CheckIcon, FlameIcon, GitBranchIcon, LifeBuoyIcon, type LucideIcon, MailIcon, MessagesSquareIcon, NotebookPenIcon, PenToolIcon, PlugIcon, PlusIcon, RotateCcwIcon, SparklesIcon, TableIcon, TelescopeIcon, UserPlusIcon, UsersIcon, XIcon, } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type KeyboardEvent, type ReactNode, useEffect, useId, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Popover, PopoverPopup, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Spinner } from "@/components/ui/spinner"; import { Textarea } from "@/components/ui/textarea"; export type FirstRunTone = "orange" | "teal" | "violet" | "amber" | "rose" | "info" | "success" | "destructive"; export interface FirstRunTool { id: string; name: string; blurb: string; icon: LucideIcon; tone: FirstRunTone; } export interface AgentTemplate { id: string; name: string; description: string; icon: LucideIcon; tone: FirstRunTone; instructions: string; } const softTone: Record = { orange: "bg-orange-500/12 text-orange-700 dark:bg-orange-400/14 dark:text-orange-300", teal: "bg-teal-500/12 text-teal-700 dark:bg-teal-400/14 dark:text-teal-300", violet: "bg-violet-500/12 text-violet-700 dark:bg-violet-400/14 dark:text-violet-300", amber: "bg-amber-500/12 text-amber-700 dark:bg-amber-400/14 dark:text-amber-300", rose: "bg-rose-500/12 text-rose-700 dark:bg-rose-400/14 dark:text-rose-300", info: "bg-info/12 text-info-foreground", success: "bg-success/12 text-success-foreground", destructive: "bg-destructive/12 text-destructive-foreground", }; const tileTone: Record = { orange: "bg-orange-700", teal: "bg-teal-700", violet: "bg-violet-600", amber: "bg-amber-700", rose: "bg-rose-700", info: "bg-blue-600", success: "bg-emerald-700", destructive: "bg-red-700", }; const defaultTools: FirstRunTool[] = [ { id: "relay", name: "Relay", blurb: "Team chat", icon: MessagesSquareIcon, tone: "info" }, { id: "forge", name: "Forge", blurb: "Code & reviews", icon: GitBranchIcon, tone: "violet" }, { id: "postbox", name: "Postbox", blurb: "Shared inbox", icon: MailIcon, tone: "orange" }, { id: "gridline", name: "Gridline", blurb: "Spreadsheets", icon: TableIcon, tone: "success" }, { id: "harbor", name: "Harbor Desk", blurb: "Support tickets", icon: LifeBuoyIcon, tone: "teal" }, { id: "inkwell", name: "Inkwell", blurb: "Design files", icon: PenToolIcon, tone: "rose" }, ]; const defaultTemplates: AgentTemplate[] = [ { id: "triage", name: "Support triage", description: "Tags and drafts first replies", icon: LifeBuoyIcon, tone: "teal", instructions: "Read every new ticket. Tag it by product area and urgency, draft a friendly first reply, and escalate anything about billing to a human.", }, { id: "research", name: "Research analyst", description: "Digs up sources, cites them", icon: TelescopeIcon, tone: "info", instructions: "When asked a question, search our docs and the web, compare at least three sources and answer with a short summary plus citations.", }, { id: "standup", name: "Standup writer", description: "Daily digest from your tools", icon: NotebookPenIcon, tone: "orange", instructions: "Every weekday at 9:00, collect yesterday's merged work and open blockers, then post a three-bullet standup per person in #team.", }, { id: "blank", name: "Start blank", description: "Write your own brief", icon: SparklesIcon, tone: "amber", instructions: "", }, ]; const defaultUseCases = ["Support", "Engineering", "Sales", "Research", "Operations", "Marketing"]; const defaultSuggestions = ["maya@northwind.io", "theo@northwind.io", "ines@northwind.io"]; const swatches: { tone: FirstRunTone; label: string }[] = [ { tone: "orange", label: "Orange" }, { tone: "teal", label: "Teal" }, { tone: "violet", label: "Violet" }, { tone: "amber", label: "Amber" }, { tone: "info", label: "Blue" }, { tone: "rose", label: "Rose" }, ]; const defaultTakenSlugs = ["northwind", "acme", "hearth", "admin"]; const defaultSlots = ["Tue, Oct 28 · 10:00", "Tue, Oct 28 · 14:30", "Wed, Oct 29 · 09:30", "Thu, Oct 30 · 16:00"]; const roleItems = { admin: "Admin", member: "Member", viewer: "Viewer" }; type Role = keyof typeof roleItems; type ToolStatus = "idle" | "connecting" | "connected"; const spring = { type: "spring", stiffness: 420, damping: 38 } as const; const easeOut = [0.23, 1, 0.32, 1] as const; const press = "transition-[box-shadow,transform,background-color] duration-150 ease-out active:scale-[0.97]"; const STEPS = ["Workspace", "Tools", "Team", "Agent"]; function slugify(v: string) { return v .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 32); } function initials(v: string) { const parts = v.trim().split(/[\s@._-]+/).filter(Boolean); return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase() || "?"; } function useReducedMotionAfterMount() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && !!prefers; } const isEmail = (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); function Rolling({ value }: { value: number | string }) { const reduce = useReducedMotionAfterMount(); return ( {value} ); } type StepStatus = "done" | "skipped" | "current" | "upcoming"; function CheckDot({ checked, active }: { checked: boolean; active?: boolean }) { const reduce = useReducedMotionAfterMount(); return ( {active && !checked && } ); } interface ChecklistItem { label: string; hint: string; done: boolean; step: number; } function Checklist({ items, current, onGo }: { items: ChecklistItem[]; current: number | null; onGo: (step: number) => void }) { const reduce = useReducedMotionAfterMount(); const doneCount = items.filter((i) => i.done).length; return (
{doneCount === items.length ? "All done" : "Your progress"}  of {items.length}
    {items.map((item) => { const active = current === item.step; return (
  1. ); })}
); } function StepTitle({ title, sub }: { title: string; sub: string }) { return (

{title}

{sub}

); } function FieldLabelText({ children, htmlFor }: { children: ReactNode; htmlFor?: string }) { return ( ); } export interface FirstRunProps { productName?: string; domain?: string; defaultWorkspaceName?: string; useCases?: string[]; tools?: FirstRunTool[]; templates?: AgentTemplate[]; inviteSuggestions?: string[]; takenSlugs?: string[]; walkthroughSlots?: string[]; connectDelay?: number; onFinish?: (summary: { workspace: string; tools: string[]; invites: number; agent: string }) => void; className?: string; } export function FirstRun({ productName = "Hearth", domain = "hearth.app", defaultWorkspaceName = "Northwind Labs", useCases = defaultUseCases, tools = defaultTools, templates = defaultTemplates, inviteSuggestions = defaultSuggestions, takenSlugs = defaultTakenSlugs, walkthroughSlots = defaultSlots, connectDelay = 1000, onFinish, className, }: FirstRunProps) { const reduce = useReducedMotionAfterMount(); const ids = useId(); const [step, setStep] = useState(0); const [dir, setDir] = useState(1); const [finished, setFinished] = useState(false); const [left, setLeft] = useState([]); const [maxReached, setMaxReached] = useState(0); const [name, setName] = useState(defaultWorkspaceName); const [slug, setSlug] = useState(slugify(defaultWorkspaceName)); const [slugEdited, setSlugEdited] = useState(false); const [picked, setPicked] = useState(["Support"]); const [color, setColor] = useState("teal"); const [toolStatus, setToolStatus] = useState>({}); const timers = useRef([]); const [draft, setDraft] = useState([]); const [emailText, setEmailText] = useState(""); const [emailError, setEmailError] = useState(""); const [shake, setShake] = useState(0); const [role, setRole] = useState("member"); const [invites, setInvites] = useState<{ email: string; role: Role }[]>([]); const [templateId, setTemplateId] = useState(templates[0]?.id ?? ""); const [agentName, setAgentName] = useState(templates[0]?.name ?? "My agent"); const [agentNameEdited, setAgentNameEdited] = useState(false); const [instructions, setInstructions] = useState(templates[0]?.instructions ?? ""); useEffect(() => { const list = timers.current; return () => list.forEach((t) => window.clearTimeout(t)); }, []); const connectedTools = tools.filter((t) => toolStatus[t.id] === "connected"); const template = templates.find((t) => t.id === templateId) ?? templates[0]; const slugTaken = takenSlugs.includes(slug); const workspaceValid = !!name.trim() && !!slug && !slugTaken; const doneByStep = [ workspaceValid && (left.includes(0) || finished), connectedTools.length > 0, invites.length > 0, finished, ]; const statuses: StepStatus[] = STEPS.map((_, i) => !finished && i === step ? "current" : doneByStep[i] ? "done" : left.includes(i) ? "skipped" : "upcoming", ); const checklist: ChecklistItem[] = [ { label: "Name your workspace", hint: name.trim() ? `${domain}/${slug || "…"}` : "Pick a name and URL", done: doneByStep[0], step: 0, }, { label: "Connect a tool", hint: connectedTools.length ? connectedTools.map((t) => t.name).join(", ") : left.includes(1) ? "Skipped for now" : `${tools .slice(0, 2) .map((t) => t.name) .join(", ")} and more`, done: doneByStep[1], step: 1, }, { label: "Invite a teammate", hint: invites.length ? `${invites.length} invite${invites.length > 1 ? "s" : ""} sent` : left.includes(2) ? "Skipped for now" : "Work is better together", done: doneByStep[2], step: 2, }, { label: "Create your first agent", hint: finished ? `${agentName} is live` : "Start from a template", done: doneByStep[3], step: 3, }, ]; const go = (target: number) => { setDir(finished || target > step ? 1 : -1); setFinished(false); setStep(target); setMaxReached((m) => Math.max(m, target)); }; const next = () => { setLeft((l) => (l.includes(step) ? l : [...l, step])); if (step === STEPS.length - 1) { setDir(1); setFinished(true); setMaxReached(STEPS.length - 1); onFinish?.({ workspace: name, tools: connectedTools.map((t) => t.name), invites: invites.length, agent: agentName, }); return; } go(step + 1); }; const restart = () => { setFinished(false); setLeft([]); setMaxReached(0); setToolStatus({}); setInvites([]); setDraft([]); setDir(-1); setStep(0); }; const onName = (v: string) => { setName(v); if (!slugEdited) setSlug(slugify(v)); }; const toggleTool = (id: string) => { const s = toolStatus[id] ?? "idle"; if (s === "connecting") return; if (s === "connected") { setToolStatus((m) => ({ ...m, [id]: "idle" })); return; } setToolStatus((m) => ({ ...m, [id]: "connecting" })); timers.current.push(window.setTimeout(() => setToolStatus((m) => ({ ...m, [id]: "connected" })), connectDelay)); }; const commitEmail = (raw: string) => { const v = raw.trim().replace(/,$/, ""); if (!v) return true; if (!isEmail(v)) { setEmailError(`“${v}” doesn't look like an email`); setShake((n) => n + 1); return false; } setEmailError(""); setDraft((d) => (d.includes(v) || invites.some((i) => i.email === v) ? d : [...d, v])); setEmailText(""); return true; }; const onEmailKey = (e: KeyboardEvent) => { if (["Enter", ",", " ", "Tab"].includes(e.key) && emailText.trim()) { e.preventDefault(); commitEmail(emailText); } else if (e.key === "Backspace" && !emailText && draft.length) { setDraft((d) => d.slice(0, -1)); } }; const sendInvites = () => { const ok = commitEmail(emailText); if (!ok) return; const pending = [...draft, ...(isEmail(emailText.trim()) ? [emailText.trim()] : [])]; if (!pending.length) return; setInvites((list) => [ ...pending.filter((e) => !list.some((i) => i.email === e)).map((email) => ({ email, role })), ...list, ]); setDraft([]); setEmailText(""); }; const pickTemplate = (t: AgentTemplate) => { setTemplateId(t.id); setInstructions(t.instructions); if (!agentNameEdited) setAgentName(t.id === "blank" ? "My agent" : t.name); }; const canNext = step === 0 ? workspaceValid : step === 3 ? !!agentName.trim() : true; const skipping = (step === 1 && !connectedTools.length) || (step === 2 && !invites.length); const variants = { enter: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, x: d * 32 }), center: { opacity: 1, x: 0 }, exit: (d: number) => ({ opacity: 0, x: reduce ? 0 : d * -24, transition: { duration: 0.12 } }), }; return (
{productName} / Set up
{!finished && (
Step {step + 1} of {STEPS.length}
{STEPS.map((sName, i) => ( ))}
)} {finished ? ( ) : ( {step === 0 && ( <>
{initials(name)}

Workspace colour

{swatches.map((sw) => ( ))}
Workspace name onName(e.target.value)} placeholder="Acme Inc." value={name} />

{name.trim() ? "Teammates see this in invites and emails." : "Give your workspace a name to continue."}

Workspace URL
{domain}/ { setSlugEdited(true); setSlug(slugify(e.target.value)); }} value={slug} /> {slug && ( {slugTaken ? : } {slugTaken ? "Taken" : "Available"} )}

{!slug ? "Pick a URL for your workspace." : slugTaken ? `${domain}/${slug} is taken. Try ${slug}-team?` : slugEdited ? "Lowercase letters, numbers and dashes." : "Filled in from the name. Edit it if you like."}

What will you use {productName} for?

{useCases.map((u) => { const on = picked.includes(u); return ( setPicked((p) => (on ? p.filter((x) => x !== u) : [...p, u]))} transition={spring} type="button" whileTap={reduce ? undefined : { scale: 0.97 }} > {on && ( )} {u} ); })}
)} {step === 1 && ( <>
{tools.map((t, i) => { const s = toolStatus[t.id] ?? "idle"; const Icon = t.icon; return ( {t.name} {t.blurb} ); })}

of {tools.length} connected · read-only until you grant more

)} {step === 2 && ( <>
Email addresses
{draft.map((d) => ( {d} ))} emailText.trim() && commitEmail(emailText)} onChange={(e) => { setEmailText(e.target.value); if (emailError) setEmailError(""); }} onKeyDown={onEmailKey} placeholder={draft.length ? "Add another" : "name@company.com"} value={emailText} />

{emailError || "Press Enter or comma to add each address."}

{inviteSuggestions.some((s) => !invites.some((i) => i.email === s) && !draft.includes(s)) && (

Suggested from your domain

{inviteSuggestions .filter((s) => !invites.some((i) => i.email === s) && !draft.includes(s)) .map((s) => ( ))}
)}
Pending invites
    {invites.length === 0 && ( No invites yet. You can always do this later. )} {invites.map((inv) => (
    {initials(inv.email)} {inv.email} Invite sent · pending {roleItems[inv.role]}
    ))}
)} {step === 3 && template && ( <>
{templates.map((t) => { const on = t.id === templateId; const Icon = t.icon; return ( ); })}
Agent name { setAgentNameEdited(true); setAgentName(e.target.value); }} aria-describedby={`${ids}-agent-hint`} aria-invalid={!agentName.trim() || undefined} value={agentName} />

{agentName.trim() ? "Shown when it posts or replies. You can rename it later." : "Your agent needs a name."}

Instructions