/** * @file OnboardingWizard.tsx * @description The first-run setup, once per ACCOUNT (docs/plans/onboarding; * modelled on Orca's welcome): 1 the default agent — the AI tools detected on * this machine, the rest behind "show more" — and the permission mode a new * session starts in (never bypass: that stays per session); 2 the look — * System / Dark / Light, a theme for each mode, language and terminal font; * 3 the corner pet (`PetSettings`); 4 notifications — permission, sound, * a test. Every choice is the same localStorage write Settings makes, so it * applies at once and is synced to the account (`lib/userPrefs.ts`). Shown * when the account has not been through it and has no workspace yet (one that * has is marked done silently), or when Settings asks for it again. The last * step hands over to the tour (`OnboardingTour`). * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { Check, ChevronLeft, Monitor, Moon, Play, Sun, Zap } from "lucide-react"; import { api } from "../../lib/api"; import { useDialog } from "../../hooks/useDialog"; import { useAiTools } from "../../hooks/useAiTools"; import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme"; import { AiToolIcon } from "../AiToolIcon"; import { Switch } from "../Switch"; import { SettingsSegmented } from "../settings/SettingsRow"; import { PetSettings } from "../settings/PetSettings"; import { Swatch, THEME_LABELS } from "../settings/AppearanceSettings"; import { markOnboarded, useOnboarding } from "../../lib/userPrefs"; import { DEFAULT_MODES, readDefaultMode, readDefaultTool, writeDefaultMode, writeDefaultTool, type DefaultMode, } from "../../lib/sessionDefaults"; import { TERMINAL_FONTS, readTerminalFont, writeTerminalFont, type TerminalFont, } from "../../lib/terminalFont"; import { NOTIF_SOUNDS, loadNotifPrefs, saveNotifPrefs, type NotifPrefs, type NotifSound, } from "../../lib/notifPrefs"; import { notify, playSound } from "../../lib/notify"; import { subscribeToPush } from "../../lib/push"; import { startTour } from "./OnboardingTour"; const STEPS = ["agent", "look", "pet", "notify"] as const; type Step = (typeof STEPS)[number]; /** Mounted once in the layout: decides whether this account sees the setup. */ export function OnboardingWizard() { const { loaded, onboardedAt, forced } = useOnboarding(); const [show, setShow] = useState(false); useEffect(() => { if (!loaded || onboardedAt) return setShow(false); if (forced) return setShow(true); let alive = true; api.lanes .list() .then((r) => { if (!alive) return; // An account already at work never needed a welcome. if (r.lanes.length) void markOnboarded(); else setShow(true); }) .catch(() => { /* cannot tell: ask again next load rather than interrupt */ }); return () => { alive = false; }; }, [loaded, onboardedAt, forced]); return show ? : null; } function Wizard() { const { t } = useTranslation("onboarding"); const navigate = useNavigate(); const [index, setIndex] = useState(0); const step: Step = STEPS[index] ?? "agent"; const last = index === STEPS.length - 1; const panelRef = useRef(null); useDialog(true, panelRef); const skip = () => void markOnboarded(); const next = () => { if (!last) return setIndex(index + 1); void markOnboarded(); startTour(); navigate("/"); }; // Ctrl/⌘+Enter continues, like Orca. const nextRef = useRef(next); nextRef.current = next; useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); nextRef.current(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); return (
{t("brand")}
{STEPS.map((s, i) => ( ))} {t("progress", { n: index + 1, total: STEPS.length })}
{step === "agent" && } {step === "look" && } {step === "pet" && ( )} {step === "notify" && }
{index > 0 && ( )}
); } function StepHead({ eyebrow, title, body, children, }: { eyebrow?: string; title: string; body: string; children: ReactNode; }) { return (
{eyebrow && (

{eyebrow}

)}

{title}

{body}

{children}
); } function AgentStep() { const { t } = useTranslation("onboarding"); const aiTools = useAiTools(); const installed = aiTools.tools.filter((x) => x.installed && x.enabled); const others = aiTools.tools.filter((x) => !(x.installed && x.enabled)); const [more, setMore] = useState(false); const [picked, setPicked] = useState(readDefaultTool); const current = (picked && installed.some((x) => x.id === picked) ? picked : null) ?? aiTools.defaultId ?? installed[0]?.id ?? null; const [mode, setMode] = useState(() => readDefaultMode() as DefaultMode); const pick = (id: string) => { writeDefaultTool(id); setPicked(id); }; return (

{t("agent.detected", { count: installed.length })}

{aiTools.loaded && !installed.length && (

{t("agent.none")}

)}
{installed.map((tool) => { const selected = tool.id === current; return ( ); })} {more && others.map((tool) => (
{tool.label} {t("agent.notInstalled")}
))}
{others.length > 0 && ( )}
{t("agent.permission")} { writeDefaultMode(m); setMode(m); }} options={DEFAULT_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))} />

{t("agent.permissionHint")}

); } const MODE_CARDS: { mode: ThemeMode; Icon: typeof Moon }[] = [ { mode: "system", Icon: Monitor }, { mode: "dark", Icon: Moon }, { mode: "light", Icon: Sun }, ]; function LookStep() { const { t, i18n } = useTranslation("onboarding"); const { t: ts } = useTranslation("settings"); const { mode, themeFor, setMode, setThemeFor } = useTheme(); const [font, setFont] = useState(readTerminalFont); return (
{MODE_CARDS.map(({ mode: m, Icon }) => { const selected = mode === m; return ( ); })}
{(["dark", "light"] as Theme[]).map((group) => (

{t(group === "dark" ? "look.darkThemes" : "look.lightThemes")}

{THEMES[group].map((name) => { const selected = themeFor[group] === name; const label = THEME_LABELS[name] ?? ts("appearance.default"); return ( ); })}
))}

{t("look.language")}

void i18n.changeLanguage(v)} options={[ { value: "en" as const, label: ts("language.names.en") }, { value: "vi" as const, label: ts("language.names.vi") }, ]} />

{t("look.font")}

{ writeTerminalFont(f); setFont(f); }} options={(Object.keys(TERMINAL_FONTS) as TerminalFont[]).map((f) => ({ value: f, label: ( {ts(`terminalFont.names.${f}`)} ), }))} />

{t("look.more")}

); } function NotifyStep() { const { t } = useTranslation("onboarding"); const { t: ts } = useTranslation("settings"); const [prefs, setPrefs] = useState(loadNotifPrefs); const [result, setResult] = useState(null); const supported = typeof window !== "undefined" && "Notification" in window; const [permission, setPermission] = useState(() => supported ? Notification.permission : "default" ); const update = (patch: Partial) => { const next = { ...prefs, ...patch }; saveNotifPrefs(next); setPrefs(next); }; const enable = async (on: boolean) => { if (!on) return update({ enabled: false }); if (supported && Notification.permission !== "granted") { const perm = await Notification.requestPermission(); setPermission(perm); if (perm !== "granted") return; } update({ enabled: true }); await subscribeToPush().catch(() => { /* the local fallback still shows them */ }); }; const test = async () => { const r = await notify(ts("notifications.testTitle"), ts("notifications.testBody")); setResult( r.via === "push" || r.via === "native" ? ts("notifications.testSent") : r.via === "local" ? ts("notifications.testLocal") : r.via === "blocked" ? ts("notifications.testBlocked") : ts("notifications.testFailed") ); }; return (

{t("notify.enable")}

{t("notify.enableHint")}

void enable(v)} label={t("notify.enable")} />
{permission === "denied" && (

{t("notify.blocked")}

)}
{t("notify.sound")}
{result && (

{result}

)}
); }