import { useState, useRef, useEffect, type KeyboardEvent, type MutableRefObject } from 'react'; import { createPortal } from 'react-dom'; import { ArrowRight, ArrowLeft, LoaderCircle, ExternalLink, ClipboardPaste, RefreshCw, Check, ChevronDown, Mic, Eye, EyeOff, Shield, ShieldCheck, ShieldOff, Copy, Smartphone, Globe, Wifi, WifiOff, TriangleAlert, X, Plus, KeyRound, Activity, Moon, Save, CalendarClock, Clock, Zap, FileText, Pause, Play, Trash2, Download, Info } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import { authFetch } from './src/lib/auth'; /* ── Access detection ── */ type AccessMethod = 'tailscale' | 'lan' | 'localhost' | 'tunnel' | 'relay' | 'custom-domain'; function detectAccessMethod(hostname: string): AccessMethod { // Tailscale CGNAT range: 100.64.0.0 – 100.127.255.255 const tailscaleMatch = hostname.match(/^100\.(\d+)\./); if (tailscaleMatch && +tailscaleMatch[1] >= 64 && +tailscaleMatch[1] <= 127) return 'tailscale'; // LAN ranges if (/^192\.168\./.test(hostname) || /^10\./.test(hostname)) return 'lan'; const m172 = hostname.match(/^172\.(\d+)\./); if (m172 && +m172[1] >= 16 && +m172[1] <= 31) return 'lan'; // Localhost if (hostname === 'localhost' || hostname === '127.0.0.1') return 'localhost'; // Cloudflare quick tunnel if (hostname.endsWith('.trycloudflare.com')) return 'tunnel'; // Relay domain if (hostname.endsWith('.bloby.bot')) return 'relay'; // Anything else is a custom domain (named tunnel) return 'custom-domain'; } function isPrivateAccess(method: AccessMethod): boolean { return method === 'tailscale' || method === 'lan' || method === 'localhost'; } const ACCESS_LABELS: Record = { tailscale: 'Tailscale', lan: 'Local network', localhost: 'Localhost', tunnel: 'Cloudflare tunnel', relay: 'Relay', 'custom-domain': 'Custom domain', }; /* ── Provider config ── */ // `iconHeight` is per-logo because each brand mark has different internal // padding — Codex's glyph is small inside its viewBox, Pi has tight margins, // etc. Tweak this number to optically balance the four logos at the same // visual weight. Default base is 30px; pass undefined to inherit it. const BASE_ICON_HEIGHT = 30; const PROVIDERS = [ { id: 'bloby', name: 'Bloby', subtitle: 'Coming Soon..', icon: '/morphy.png', comingSoon: true, iconHeight: 30 }, { id: 'anthropic', name: 'Claude', subtitle: 'By\nAnthropic', icon: '/icons/claude.png', comingSoon: false, iconHeight: 30 }, { id: 'openai', name: 'Codex', subtitle: 'By\nOpenAI', icon: '/codex.svg', comingSoon: false, iconHeight: 34 }, { id: 'pi', name: 'Pi', subtitle: 'Bring your\nown model', icon: '/pi-logo.svg', comingSoon: false, iconHeight: 33 }, ] as const; const MODELS: Record = { anthropic: [ { id: 'claude-opus-4-8[1m]', label: 'Opus 4.8 (1M context)' }, { id: 'claude-opus-4-8', label: 'Opus 4.8' }, { id: 'claude-opus-4-7[1m]', label: 'Opus 4.7 (1M context)' }, { id: 'claude-opus-4-7', label: 'Opus 4.7' }, { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6 (1M context)' }, { id: 'claude-haiku-4-5', label: 'Haiku 4.5' }, ], openai: [ { id: 'gpt-5.5:high', label: 'GPT-5.5 High' }, { id: 'gpt-5.5:medium', label: 'GPT-5.5 Medium' }, { id: 'gpt-5.5:xhigh', label: 'GPT-5.5 Extra High' }, { id: 'gpt-5.4:medium', label: 'GPT-5.4 Medium' }, { id: 'gpt-5.4:high', label: 'GPT-5.4 High' }, { id: 'gpt-5.4:xhigh', label: 'GPT-5.4 Extra High' }, { id: 'gpt-5.4-mini:medium', label: 'GPT-5.4-Mini Medium' }, { id: 'gpt-5.4-mini:high', label: 'GPT-5.4-Mini High' }, { id: 'gpt-5.4-mini:xhigh', label: 'GPT-5.4-Mini Extra High' }, ], }; // TOTAL_STEPS is dynamic — set inside the component based on isInitialSetup const HANDLES = [ { tier: 'at', prefix: 'open.bloby.bot/', label: (n: string) => `open.bloby.bot/${n}`, badge: 'Free', badgeCls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20', highlight: false }, { tier: 'premium', prefix: 'bloby.bot/', label: (n: string) => `bloby.bot/${n}`, badge: '$5', badgeCls: 'bg-[#0069FE]/15 text-[#0069FE] border-[#0069FE]/20', highlight: true }, ] as const; /* ── Dropdown ── */ function ModelDropdown({ models, value, onChange, placeholder = 'Choose a model...', menuMaxPx = 320 }: { models: { id: string; label: string }[]; value: string; onChange: (id: string) => void; placeholder?: string; menuMaxPx?: number }) { const [open, setOpen] = useState(false); const [pos, setPos] = useState<{ left: number; width: number; top?: number; bottom?: number; maxHeight: number } | null>(null); const btnRef = useRef(null); const menuRef = useRef(null); useEffect(() => { if (!open) return; const recalc = () => { const r = btnRef.current?.getBoundingClientRect(); if (!r) return; const margin = 8; const spaceBelow = window.innerHeight - r.bottom - margin; const spaceAbove = r.top - margin; // Flip the menu upward when there isn't enough room below and there's more above; either way // cap its height to the space available so it never spills past the viewport edge. const openUp = spaceBelow < Math.min(menuMaxPx, 220) && spaceAbove > spaceBelow; const maxHeight = Math.max(120, Math.min(menuMaxPx, openUp ? spaceAbove : spaceBelow)); setPos(openUp ? { left: r.left, width: r.width, bottom: window.innerHeight - r.top + 4, maxHeight } : { left: r.left, width: r.width, top: r.bottom + 4, maxHeight }); }; recalc(); const handler = (e: MouseEvent) => { const t = e.target as Node; if (btnRef.current?.contains(t)) return; if (menuRef.current?.contains(t)) return; setOpen(false); }; document.addEventListener('mousedown', handler); window.addEventListener('resize', recalc); window.addEventListener('scroll', recalc, true); return () => { document.removeEventListener('mousedown', handler); window.removeEventListener('resize', recalc); window.removeEventListener('scroll', recalc, true); }; }, [open]); const selected = models.find((m) => m.id === value); return (
{open && pos && createPortal(
{models.map((m) => ( ))}
, document.body, )}
); } /* ── Settings mode: screen registry + jump menu ── */ // The jumpable settings screens (settings re-run only). `step` maps to the wizard step index. // Environment Variables (step 6) reuses the slot the onboarding-only "All Set" screen occupies. const SETTINGS_SCREENS: { step: number; label: string }[] = [ { step: 1, label: 'Personal Info' }, { step: 2, label: 'Agent Name & Access' }, { step: 3, label: 'Security' }, { step: 4, label: 'AI Provider' }, { step: 5, label: 'Voice Messages' }, { step: 6, label: 'Environment Variables' }, { step: 7, label: 'Pulse & Crons' }, ]; // Compact "Go to" dropdown shown in the settings header so the user can jump to any screen. function GoToMenu({ onJump }: { onJump: (step: number) => void }) { const [open, setOpen] = useState(false); const [pos, setPos] = useState<{ right: number; top?: number; bottom?: number; maxHeight: number } | null>(null); const btnRef = useRef(null); const menuRef = useRef(null); useEffect(() => { if (!open) return; const recalc = () => { const r = btnRef.current?.getBoundingClientRect(); if (!r) return; const margin = 8; const spaceBelow = window.innerHeight - r.bottom - margin; const spaceAbove = r.top - margin; const openUp = spaceBelow < Math.min(188, 220) && spaceAbove > spaceBelow; const maxHeight = Math.max(120, Math.min(188, openUp ? spaceAbove : spaceBelow)); setPos(openUp ? { right: window.innerWidth - r.right, bottom: window.innerHeight - r.top + 6, maxHeight } : { right: window.innerWidth - r.right, top: r.bottom + 6, maxHeight }); }; recalc(); const handler = (e: MouseEvent) => { const t = e.target as Node; if (btnRef.current?.contains(t) || menuRef.current?.contains(t)) return; setOpen(false); }; document.addEventListener('mousedown', handler); window.addEventListener('resize', recalc); window.addEventListener('scroll', recalc, true); return () => { document.removeEventListener('mousedown', handler); window.removeEventListener('resize', recalc); window.removeEventListener('scroll', recalc, true); }; }, [open]); return ( <> {open && pos && createPortal(
{SETTINGS_SCREENS.map((s) => ( ))}
, document.body, )} ); } /* ── Settings mode: Environment Variables screen ── */ interface EnvVarRow { name: string; value: string } interface EnvVarGroup { title: string; vars: EnvVarRow[] } interface EnvCache { groups: EnvVarGroup[]; values: Record; originals: Record; revealed: string[]; removed: string[]; newRows: { id: number; name: string; value: string }[]; newRowSeq: number; saved: boolean; } const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; // A read/write view of the agent-controlled `workspace/.env`, grouped by `# Section` headers. // Reads via GET /api/env, writes changed/added keys via POST /api/env (which restarts the backend). // Self-saving and self-contained — not part of the wizard's batched onboard payload. // `cacheRef` lets the working state survive this screen's unmount/remount when the user // navigates to another settings screen and back (the wizard keys each step's motion.div by // `step`, so it remounts). It lives in the parent and dies with the wizard, so each fresh // open re-fetches from /api/env. function EnvSettings({ cacheRef }: { cacheRef: MutableRefObject }) { // Whether we already have cached working state at mount — captured once per instance. const hadCache = useRef(cacheRef.current != null).current; const cached = cacheRef.current; const [groups, setGroups] = useState(cached?.groups ?? []); const [loading, setLoading] = useState(!hadCache); const [loadError, setLoadError] = useState(''); const [values, setValues] = useState>(cached?.values ?? {}); const [originals, setOriginals] = useState>(cached?.originals ?? {}); const [revealed, setRevealed] = useState>(new Set(cached?.revealed ?? [])); const [removed, setRemoved] = useState>(new Set(cached?.removed ?? [])); const [newRows, setNewRows] = useState<{ id: number; name: string; value: string }[]>(cached?.newRows ?? []); const newRowId = useRef(cached?.newRowSeq ?? 0); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(cached?.saved ?? false); const [saveError, setSaveError] = useState(''); useEffect(() => { if (hadCache) return; // hydrated from cache — don't clobber edits with a refetch let cancelled = false; authFetch('/api/env') .then((r) => (r.ok ? r.json() : Promise.reject(new Error('Failed to load environment variables')))) .then((data) => { if (cancelled) return; const gs: EnvVarGroup[] = Array.isArray(data.groups) ? data.groups : []; const init: Record = {}; for (const g of gs) for (const v of g.vars) init[v.name] = v.value; setGroups(gs); setValues(init); setOriginals(init); setLoading(false); }) .catch((err) => { if (!cancelled) { setLoadError(err.message || 'Failed to load'); setLoading(false); } }); return () => { cancelled = true; }; }, [hadCache]); // Persist working state to the parent ref so navigating away and back doesn't lose edits. // Skip while loading so a navigate-away mid-fetch leaves the cache empty → refetch on return. useEffect(() => { if (loading) return; cacheRef.current = { groups, values, originals, revealed: Array.from(revealed), removed: Array.from(removed), newRows, newRowSeq: newRowId.current, saved }; }); const validNewRows = newRows.filter((r) => r.name.trim() && r.value.trim() && ENV_NAME_RE.test(r.name.trim())); const removedExisting = [...removed].filter((k) => k in originals); // An edit to a var that's also marked for removal doesn't count — removal wins. const changedExisting = Object.keys(values).filter((k) => values[k] !== originals[k] && !removed.has(k)); const dirty = changedExisting.length > 0 || validNewRows.length > 0 || removedExisting.length > 0; const toggleReveal = (name: string) => { setRevealed((prev) => { const next = new Set(prev); if (next.has(name)) next.delete(name); else next.add(name); return next; }); }; const toggleRemoved = (name: string) => { setSaved(false); setRemoved((prev) => { const next = new Set(prev); if (next.has(name)) next.delete(name); else next.add(name); return next; }); }; const handleSave = async () => { if (!dirty || saving) return; setSaving(true); setSaveError(''); setSaved(false); const vars: Record = {}; for (const k of changedExisting) vars[k] = values[k]; for (const r of validNewRows) vars[r.name.trim()] = r.value.trim(); const removeList = removedExisting; try { const res = await authFetch('/api/env', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ vars, remove: removeList }), }); if (!res.ok) { const d = await res.json().catch(() => ({ error: 'Save failed' })); throw new Error(d.error || 'Save failed'); } // Snapshot of values including the just-added rows and minus the removed ones, so `dirty` // resets after save. const merged = { ...values }; for (const r of validNewRows) merged[r.name.trim()] = r.value.trim(); for (const k of removeList) delete merged[k]; // Drop removed vars from the displayed groups; surface newly-added vars under a "Custom" group // (the writer appends them; on next load they'll re-group under whatever section precedes them). setGroups((prev) => { let next = prev.map((g) => ({ ...g, vars: g.vars.filter((v) => !removed.has(v.name)) })); const existing = new Set(next.flatMap((g) => g.vars.map((v) => v.name))); const toAdd = validNewRows.map((r) => r.name.trim()).filter((nm) => !existing.has(nm)); if (toAdd.length) { let custom = next.find((g) => g.title === 'Custom'); if (!custom) { custom = { title: 'Custom', vars: [] }; next.push(custom); } for (const nm of toAdd) custom.vars.push({ name: nm, value: merged[nm] }); } return next.filter((g) => g.vars.length > 0); // hide groups emptied by removal }); setValues(merged); setOriginals(merged); setNewRows([]); setRemoved(new Set()); setSaved(true); setSaving(false); } catch (err: any) { setSaveError(err.message || 'Save failed'); setSaving(false); } }; return (

Environment Variables

API keys and secrets stored in your workspace{' '} .env. Your agent reads and writes these as you build.

{loading ? (
) : loadError ? (
{loadError}
) : (
{groups.length === 0 && newRows.length === 0 && (

No environment variables yet.

Ask your agent to add an integration, or add one manually below.

)} {groups.map((g) => (
{g.title || 'Other'}
{g.vars.map((v) => { const isRemoved = removed.has(v.name); const changed = !isRemoved && values[v.name] !== originals[v.name]; const show = revealed.has(v.name); return (
{isRemoved ? (
••••••••
) : (
setValues((p) => ({ ...p, [v.name]: e.target.value }))} autoComplete="off" spellCheck={false} data-1p-ignore data-lpignore="true" className="w-full bg-white/[0.03] border border-white/[0.08] text-white rounded-xl pl-4 pr-10 py-2.5 text-[13px] font-mono outline-none focus:border-[#0069FE]/30 transition-colors" />
)}
); })}
))} {newRows.length > 0 && (
New
{newRows.map((r) => { const nameInvalid = r.name.trim().length > 0 && !ENV_NAME_RE.test(r.name.trim()); return (
setNewRows((p) => p.map((x) => (x.id === r.id ? { ...x, name: e.target.value } : x)))} placeholder="VARIABLE_NAME" autoComplete="off" spellCheck={false} data-1p-ignore data-lpignore="true" className={`w-full bg-white/[0.03] border ${nameInvalid ? 'border-red-500/40' : 'border-white/[0.08]'} text-white rounded-xl px-4 py-2.5 text-[13px] font-mono outline-none focus:border-[#0069FE]/30 transition-colors`} /> setNewRows((p) => p.map((x) => (x.id === r.id ? { ...x, value: e.target.value } : x)))} placeholder="value" autoComplete="off" spellCheck={false} data-1p-ignore data-lpignore="true" className="w-full bg-white/[0.03] border border-white/[0.08] text-white rounded-xl px-4 py-2.5 text-[13px] font-mono outline-none focus:border-[#0069FE]/30 transition-colors" /> {nameInvalid && (

Use letters, numbers and underscores; can't start with a number.

)}
); })}
)}
)} {dirty && !saving && (

Changes detected. Your workspace backend will be restarted when you save.

)} {saved && !dirty && (

Saved — your workspace backend is restarting.

)} {saveError && (
{saveError}
)}
); } /* ── Settings mode: Pulse & Crons screen ── */ interface PulseConfig { enabled: boolean; intervalMinutes: number; quietHours: { start: string; end: string }; } interface CronView { id: string; schedule: string; task: string; oneShot?: boolean; paused?: boolean; hasTaskFile: boolean; nextRun: string | null; // ISO description: string; // humanized schedule } interface PulseCronCache { pulse: PulseConfig; original: PulseConfig | null; crons: CronView[] } const DEFAULT_PULSE: PulseConfig = { enabled: false, intervalMinutes: 60, quietHours: { start: '22:00', end: '07:00' } }; // 8 slider stops; the last (-1) is the "Custom" sentinel. const PULSE_STOPS: { mins: number; label: string }[] = [ { mins: 10, label: '10m' }, { mins: 30, label: '30m' }, { mins: 60, label: '1h' }, { mins: 120, label: '2h' }, { mins: 180, label: '3h' }, { mins: 360, label: '6h' }, { mins: 720, label: '12h' }, { mins: -1, label: 'Custom' }, ]; const PULSE_CUSTOM_INDEX = PULSE_STOPS.length - 1; const PULSE_TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; function pulseIndexForMinutes(mins: number): number { const i = PULSE_STOPS.findIndex((s) => s.mins === mins); return i === -1 ? PULSE_CUSTOM_INDEX : i; } function humanizeInterval(mins: number): string { if (!Number.isFinite(mins) || mins <= 0) return 'Every —'; if (mins % 60 === 0) { const h = mins / 60; return `Every ${h} hour${h === 1 ? '' : 's'}`; } if (mins < 60) return `Every ${mins} minute${mins === 1 ? '' : 's'}`; return `Every ${Math.floor(mins / 60)}h ${mins % 60}m`; } // nextRun ISO → short human string ("in 12m", "Today 3:00 PM", "Tomorrow 9:00 AM", "Jun 9, 3:00 PM"). function formatNextRun(iso: string | null): string | null { if (!iso) return null; const d = new Date(iso); if (isNaN(d.getTime())) return null; const now = new Date(); const diffMs = d.getTime() - now.getTime(); const time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); if (diffMs > 0 && diffMs < 60 * 60 * 1000) return `in ${Math.max(1, Math.round(diffMs / 60000))}m`; if (diffMs > 0 && diffMs < 6 * 60 * 60 * 1000) return `in ${Math.round(diffMs / 3_600_000)}h`; const startOfDay = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()); const dayDiff = Math.round((startOfDay(d).getTime() - startOfDay(now).getTime()) / 86_400_000); if (dayDiff === 0) return `Today ${time}`; if (dayDiff === 1) return `Tomorrow ${time}`; if (dayDiff > 1 && dayDiff < 7) return `${d.toLocaleDateString([], { weekday: 'long' })} ${time}`; return `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })}, ${time}`; } function PulseSection({ pulse, original, setPulse, onPulseSaved }: { pulse: PulseConfig; original: PulseConfig | null; setPulse: (next: PulseConfig) => void; onPulseSaved: (saved: PulseConfig) => void; }) { const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const [saveError, setSaveError] = useState(''); // Explicit "Custom" mode — NOT derived from the value, otherwise typing a value that happens to // equal a preset (e.g. 60) would snap the slider and unmount the input mid-keystroke. Seeded from // whether the loaded interval is a non-preset value. const [customMode, setCustomMode] = useState(pulseIndexForMinutes(pulse.intervalMinutes) === PULSE_CUSTOM_INDEX); const presetIdx = pulseIndexForMinutes(pulse.intervalMinutes); const onCustom = customMode; const sliderIdx = customMode ? PULSE_CUSTOM_INDEX : presetIdx; const quietValid = PULSE_TIME_RE.test(pulse.quietHours.start) && PULSE_TIME_RE.test(pulse.quietHours.end); const intervalValid = Number.isFinite(pulse.intervalMinutes) && pulse.intervalMinutes >= 1 && pulse.intervalMinutes <= 1440; const dirty = original != null && ( pulse.enabled !== original.enabled || pulse.intervalMinutes !== original.intervalMinutes || pulse.quietHours.start !== original.quietHours.start || pulse.quietHours.end !== original.quietHours.end ); const canSave = dirty && intervalValid && quietValid && !saving; const patch = (p: Partial) => { if (saved) setSaved(false); if (saveError) setSaveError(''); setPulse({ ...pulse, ...p }); }; const onSlider = (idx: number) => { if (idx === PULSE_CUSTOM_INDEX) { setCustomMode(true); // Keep the current value when already custom; seed 45 only when entering from a preset. const seed = (customMode || presetIdx === PULSE_CUSTOM_INDEX) ? pulse.intervalMinutes : 45; patch({ intervalMinutes: seed }); } else { setCustomMode(false); patch({ intervalMinutes: PULSE_STOPS[idx].mins }); } }; const handleSave = async () => { if (!canSave) return; setSaving(true); setSaveError(''); setSaved(false); try { const res = await authFetch('/api/pulse', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: pulse.enabled, intervalMinutes: pulse.intervalMinutes, quietHours: pulse.quietHours }), }); if (!res.ok) { const d = await res.json().catch(() => ({ error: 'Save failed' })); throw new Error(d.error || 'Save failed'); } onPulseSaved(pulse); setSaved(true); setSaving(false); } catch (err: any) { setSaveError(err?.message || 'Save failed'); setSaving(false); } }; return (

Pulse

Pulse periodically wakes the agent inside the main session, letting it surface anything that needs attention without spamming you.

Frequency {onCustom ? 'Custom interval' : humanizeInterval(pulse.intervalMinutes)}
onSlider(Number(e.target.value))} aria-label="Pulse interval" className="w-full h-1.5 appearance-none rounded-full bg-white/[0.08] accent-[#0069FE] cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-[0_0_0_3px_rgba(0,105,254,0.35)] [&::-webkit-slider-thumb]:cursor-pointer [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:shadow-[0_0_0_3px_rgba(0,105,254,0.35)]" />
{PULSE_STOPS.map((s, i) => ( ))}
{onCustom && (
{ const n = parseInt(e.target.value, 10); patch({ intervalMinutes: Number.isFinite(n) ? n : NaN }); }} placeholder="45" className="w-28 bg-white/[0.03] border border-white/[0.08] text-white rounded-xl px-4 py-2.5 text-[13px] outline-none focus:border-[#0069FE]/30 transition-colors placeholder:text-white/20 font-mono" /> minutes between pulses
)} {onCustom && !intervalValid && (

Enter a whole number of minutes (1–1440).

)}
Quiet hours
patch({ quietHours: { ...pulse.quietHours, start: e.target.value } })} className="w-full bg-white/[0.03] border border-white/[0.08] text-white rounded-xl px-4 py-2.5 text-[13px] outline-none focus:border-[#0069FE]/30 transition-colors [color-scheme:dark]" />
patch({ quietHours: { ...pulse.quietHours, end: e.target.value } })} className="w-full bg-white/[0.03] border border-white/[0.08] text-white rounded-xl px-4 py-2.5 text-[13px] outline-none focus:border-[#0069FE]/30 transition-colors [color-scheme:dark]" />

No pulses fire between these times.

{saved && !dirty && (

Saved — the scheduler picks this up within a minute.

)} {saveError && (
{saveError}
)} {dirty && ( )}
); } function CronsSection({ crons, onChanged, onLocalUpdate }: { crons: CronView[]; onChanged: () => void; onLocalUpdate: (fn: (prev: CronView[]) => CronView[]) => void; }) { const [busy, setBusy] = useState>({}); const [confirmId, setConfirmId] = useState(null); const [downloading, setDownloading] = useState>({}); const [actionError, setActionError] = useState(''); const [expanded, setExpanded] = useState>(new Set()); // collapsed by default const setRowBusy = (id: string, v: 'pause' | 'delete' | undefined) => setBusy((p) => ({ ...p, [id]: v })); const toggleExpanded = (id: string) => setExpanded((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); const handleTogglePause = async (c: CronView) => { if (busy[c.id]) return; setActionError(''); setRowBusy(c.id, 'pause'); try { const res = await authFetch('/api/crons/pause', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: c.id, paused: !c.paused }), }); if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.error || 'Failed to update'); } // Optimistic: reflect the change immediately so a failed background refetch can't leave a // stale row; onChanged() then reconciles against the on-disk truth. onLocalUpdate((prev) => prev.map((x) => (x.id === c.id ? { ...x, paused: !c.paused } : x))); onChanged(); } catch (err: any) { setActionError(err.message || 'Failed to update'); } finally { setRowBusy(c.id, undefined); } }; const handleDelete = async (c: CronView) => { if (busy[c.id]) return; setActionError(''); setRowBusy(c.id, 'delete'); try { const res = await authFetch('/api/crons/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: c.id }), }); if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.error || 'Failed to delete'); } setConfirmId(null); onLocalUpdate((prev) => prev.filter((x) => x.id !== c.id)); onChanged(); } catch (err: any) { setActionError(err.message || 'Failed to delete'); } finally { setRowBusy(c.id, undefined); } }; // Authed download: the endpoint requires a Bearer token, so a plain would 401. const handleDownloadTask = async (c: CronView) => { if (downloading[c.id]) return; setActionError(''); setDownloading((p) => ({ ...p, [c.id]: true })); try { const res = await authFetch(`/api/crons/task?id=${encodeURIComponent(c.id)}`); if (!res.ok) throw new Error('Could not download task file'); const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${c.id}.md`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } catch (err: any) { setActionError(err.message || 'Download failed'); } finally { setDownloading((p) => ({ ...p, [c.id]: false })); } }; return (

Crons

Crons let your agent schedule tasks inside the main session, running them daily, hourly, or on any schedule you choose. They can also be one-shot tasks that run once at a specific time.

To change or add a cron, just ask your agent. To delete or pause, use the buttons below.
{actionError && (
{actionError}
)}
{crons.length === 0 ? (

No scheduled tasks yet.

Ask your agent to schedule something.

) : ( crons.map((c) => { const paused = !!c.paused; const rowBusy = busy[c.id]; const isOpen = expanded.has(c.id); const nextStr = formatNextRun(c.nextRun); return (
{/* Header row — title + actions (always visible) */}
{confirmId === c.id ? (
Delete?
) : (
)}
{/* Expanded detail — schedule, next run, task file */} {isOpen && (
{c.description} {c.oneShot && · One-time} {/* paused is already shown by the header pill — don't repeat it here */} {!paused && nextStr && · Next: {nextStr}}
{c.hasTaskFile && ( )}
)}
); }) )}
); } // Settings-only screen (step 7). Owns the single GET /api/schedule load; caches working state in a // parent ref so edits survive the per-step remount (the wizard keys each step's motion.div by step). function PulseCronSettings({ cacheRef }: { cacheRef: MutableRefObject }) { const hadCache = useRef(cacheRef.current != null).current; const cached = cacheRef.current; const [loading, setLoading] = useState(!hadCache); const [loadError, setLoadError] = useState(''); const [pulse, setPulse] = useState(cached?.pulse ?? DEFAULT_PULSE); const [originalPulse, setOriginalPulse] = useState(cached?.original ?? null); const [crons, setCrons] = useState(cached?.crons ?? []); const fetchSchedule = async (withPulse: boolean) => { const res = await authFetch('/api/schedule'); if (!res.ok) throw new Error('Failed to load schedule'); const d = await res.json(); setCrons(Array.isArray(d.crons) ? d.crons : []); if (withPulse) { const p: PulseConfig = { enabled: !!d.pulse?.enabled, intervalMinutes: Number(d.pulse?.intervalMinutes) || 60, quietHours: { start: d.pulse?.quietHours?.start ?? '22:00', end: d.pulse?.quietHours?.end ?? '07:00' }, }; setPulse(p); setOriginalPulse(p); } }; useEffect(() => { if (hadCache) return; let cancelled = false; fetchSchedule(true) .then(() => { if (!cancelled) setLoading(false); }) .catch((err) => { if (!cancelled) { setLoadError(err.message || 'Failed to load'); setLoading(false); } }); return () => { cancelled = true; }; }, [hadCache]); // Persist working state so navigating away and back doesn't lose pulse edits. useEffect(() => { if (loading) return; cacheRef.current = { pulse, original: originalPulse, crons }; }); return (

Pulse & Crons

Background wake-ups and scheduled tasks your agent runs on its own.

{loading ? (
) : loadError ? (
{loadError}
) : (
setOriginalPulse(saved)} /> setCrons(fn)} onChanged={() => { fetchSchedule(false).catch(() => {}); }} />
)}
); } /* ── Component ── */ interface Props { onComplete: () => void; isInitialSetup?: boolean; onSave?: (payload: any) => Promise; onTunnelSwitch?: (newMode: 'off' | 'quick') => Promise; } export default function OnboardWizard({ onComplete, isInitialSetup = false, onSave, onTunnelSwitch }: Props) { // 0..5 shared. Step 6 = "All Set" (onboarding) OR "Environment Variables" (settings re-run). const TOTAL_STEPS = 7; const [step, setStep] = useState(0); const [userName, setUserName] = useState(''); const [provider, setProvider] = useState('anthropic'); const [model, setModel] = useState(''); const [saving, setSaving] = useState(false); // Auth state per provider const [authState, setAuthState] = useState>({ anthropic: 'idle', openai: 'idle', pi: 'idle', }); /* ── Bloby (pi) state ── */ interface PiSubProviderInfo { id: string; name: string; subtitle: string; flavor: string; baseUrl?: string; needsBaseUrl: boolean; needsApiKey: boolean; apiKeyUrl?: string; models: { id: string; label: string }[] | 'dynamic'; defaultModel?: string; } const [piSubProviders, setPiSubProviders] = useState([]); const [piSubProvider, setPiSubProvider] = useState(''); const [piApiKey, setPiApiKey] = useState(''); const [piBaseUrl, setPiBaseUrl] = useState(''); const [piModelId, setPiModelId] = useState(''); const [piShowKey, setPiShowKey] = useState(false); const [piConnecting, setPiConnecting] = useState(false); const [piError, setPiError] = useState(); const [piSavedStatus, setPiSavedStatus] = useState<{ subProvider?: string; modelId?: string; baseUrl?: string } | null>(null); // Anthropic/Claude-specific const [oauthStarted, setOauthStarted] = useState(false); const [anthropicCode, setAnthropicCode] = useState(''); const [isExchanging, setIsExchanging] = useState(false); const [anthropicError, setAnthropicError] = useState(); const [anthropicChecking, setAnthropicChecking] = useState(false); // OpenAI/Codex-specific // Two flows are supported: device-code (default, headless-friendly) and // paste-back (fallback for users who prefer the browser-callback flow). const [codexFlow, setCodexFlow] = useState<'device' | 'paste'>('device'); // Device-code flow const [codexDeviceState, setCodexDeviceState] = useState<'idle' | 'pending' | 'success' | 'error'>('idle'); const [codexUserCode, setCodexUserCode] = useState(''); const [codexVerificationUrl, setCodexVerificationUrl] = useState(''); const [codexDeviceStarting, setCodexDeviceStarting] = useState(false); const [codexCodeCopied, setCodexCodeCopied] = useState(false); // Paste-back flow const [codexOauthStarted, setCodexOauthStarted] = useState(false); const [codexCode, setCodexCode] = useState(''); const [codexExchanging, setCodexExchanging] = useState(false); const [openaiError, setOpenaiError] = useState(); const [codexChecking, setCodexChecking] = useState(false); // Bot name + Handle (step 2) const [botName, setBotName] = useState(''); const [handleStatus, setHandleStatus] = useState(null); const [handleError, setHandleError] = useState(''); const [tierAvailability, setTierAvailability] = useState>({}); const [selectedTier, setSelectedTier] = useState(''); const [registering, setRegistering] = useState(false); const [registered, setRegistered] = useState(false); const [registeredUrl, setRegisteredUrl] = useState(''); const handleDebounce = useRef | null>(null); // Tunnel mode (step 2 branching) const [tunnelMode, setTunnelMode] = useState<'quick' | 'named' | 'off'>('quick'); const [tunnelDomain, setTunnelDomain] = useState(''); const [tunnelUrl, setTunnelUrl] = useState(''); const [handleChoice, setHandleChoice] = useState<'tunnel' | 'relay'>('relay'); // Existing handle (for re-run / change flow) const [existingHandle, setExistingHandle] = useState<{ username: string; tier: string; url: string } | null>(null); const [showChangeConfirm, setShowChangeConfirm] = useState(false); const [changingHandle, setChangingHandle] = useState(false); // Reserved handle claim flow const [tierReserved, setTierReserved] = useState>({}); const [showClaimInput, setShowClaimInput] = useState(false); const [claimCode, setClaimCode] = useState(''); const [claimError, setClaimError] = useState(''); const [claiming, setClaiming] = useState(false); // Access detection + Tailscale-only switch const [accessMethod, setAccessMethod] = useState('tunnel'); const [showTailscaleSwitch, setShowTailscaleSwitch] = useState(false); const [tailscaleConfirmText, setTailscaleConfirmText] = useState(''); const [tailscaleSwitching, setTailscaleSwitching] = useState(false); const [tailscaleSwitchError, setTailscaleSwitchError] = useState(''); const [showReEnableTunnel, setShowReEnableTunnel] = useState(false); const [reEnabling, setReEnabling] = useState(false); const [reEnableError, setReEnableError] = useState(''); // Portal credentials (step 3) const [portalUser, setPortalUser] = useState('admin'); const [portalPass, setPortalPass] = useState(''); const [portalPassConfirm, setPortalPassConfirm] = useState(''); const [portalCopied, setPortalCopied] = useState(false); const [portalExists, setPortalExists] = useState(false); const [showPassAllSet, setShowPassAllSet] = useState(false); const [acceptedTerms, setAcceptedTerms] = useState(false); // Portal old password (for changing existing credentials) const [portalOldPass, setPortalOldPass] = useState(''); const [portalOldPassError, setPortalOldPassError] = useState(''); const [portalOldPassVerified, setPortalOldPassVerified] = useState(false); const [portalVerifying, setPortalVerifying] = useState(false); // When the portal already has credentials, password fields are hidden by // default so the user doesn't think they must re-authenticate to proceed. // Toggling this opens the change-password sub-form. const [portalChangeMode, setPortalChangeMode] = useState(false); // Whisper (step 5) const [whisperEnabled, setWhisperEnabled] = useState(false); const [whisperKey, setWhisperKey] = useState(''); // TOTP 2FA (step 3) const [totpEnabled, setTotpEnabled] = useState(false); const [totpSecret, setTotpSecret] = useState(''); const [totpQrUri, setTotpQrUri] = useState(''); const [totpOtpauthUri, setTotpOtpauthUri] = useState(''); const [totpCode, setTotpCode] = useState(''); const [totpVerified, setTotpVerified] = useState(false); const [totpVerifying, setTotpVerifying] = useState(false); const [totpError, setTotpError] = useState(''); const [recoveryCodes, setRecoveryCodes] = useState([]); const [recoveryCodesCopied, setRecoveryCodesCopied] = useState(false); const [totpDisabling, setTotpDisabling] = useState(false); const [totpDisableCode, setTotpDisableCode] = useState(''); const [totpDisableError, setTotpDisableError] = useState(''); const [isMobileDevice, setIsMobileDevice] = useState(false); // Sub-phase within step 3: 'password' | 'totp-setup' | 'recovery' const [step3Phase, setStep3Phase] = useState<'password' | 'totp-setup' | 'recovery'>('password'); // Clipboard feedback for TOTP secret copy const [totpSecretCopied, setTotpSecretCopied] = useState(false); // Pre-fill guard const prefillDone = useRef(false); // Environment Variables screen working-state cache (settings mode). Survives the per-step // remount so edits aren't lost when navigating between settings screens; dies with the wizard. const envCacheRef = useRef(null); // Same pattern for the Pulse & Crons screen (step 7). const pulseCacheRef = useRef(null); // Scrollable content region (card is viewport-capped); reset to top on each step change. const contentScrollRef = useRef(null); // Settings mode (hub-and-spoke): each screen saves itself. `settingsOrig` is the last-saved // snapshot used both to show a Save button only when a screen actually changed, and to build a // payload that touches only the active screen's fields (the worker's /api/onboard is a full // overwrite, so other screens' fields must be sent at their saved values). const [settingsOrig, setSettingsOrig] = useState({ userName: '', botName: '', provider: '', model: '', whisperEnabled: false, whisperKey: '' }); const [settingsSaving, setSettingsSaving] = useState(false); const [settingsSaved, setSettingsSaved] = useState(false); const [settingsSaveError, setSettingsSaveError] = useState(''); const isConnected = authState[provider] === 'connected'; // Persist/restore TOTP setup state across page reloads (mobile: OS suspends PWA on app-switch) const TOTP_STORAGE_KEY = 'bloby_totp_setup'; function saveTotpState() { try { sessionStorage.setItem(TOTP_STORAGE_KEY, JSON.stringify({ secret: totpSecret, qrUri: totpQrUri, otpauthUri: totpOtpauthUri, phase: step3Phase, // Also persist password fields so user doesn't re-type after returning portalPass, portalPassConfirm, })); } catch {} } function restoreTotpState() { try { const raw = sessionStorage.getItem(TOTP_STORAGE_KEY); if (!raw) return false; const saved = JSON.parse(raw); if (saved.secret && saved.phase === 'totp-setup') { setTotpSecret(saved.secret); setTotpQrUri(saved.qrUri || ''); setTotpOtpauthUri(saved.otpauthUri || ''); setTotpEnabled(true); setStep3Phase('totp-setup'); setStep(3); if (saved.portalPass) { setPortalPass(saved.portalPass); setPortalPassConfirm(saved.portalPassConfirm || ''); } return true; } } catch {} return false; } function clearTotpStorage() { try { sessionStorage.removeItem(TOTP_STORAGE_KEY); } catch {} } // Pre-fill from existing settings (re-run wizard) useEffect(() => { fetch('/api/onboard/status') .then((r) => r.json()) .then((data) => { if (data.userName) setUserName(data.userName); if (data.handle) { setBotName(data.handle.username); setSelectedTier(data.handle.tier || 'at'); setExistingHandle({ username: data.handle.username, tier: data.handle.tier, url: data.handle.url }); setRegistered(true); setRegisteredUrl(data.handle.url); } else if (data.agentName) { // Private-network mode has no handle — prefill the agent name so it isn't blank. setBotName(data.agentName); } if (data.portalUser) setPortalUser(data.portalUser); if (data.portalConfigured) setPortalExists(true); if (data.provider) setProvider(data.provider); if (data.model) setModel(data.model); if (data.whisperEnabled) { setWhisperEnabled(true); setWhisperKey(data.whisperKey || ''); } if (data.totpEnabled) { setTotpEnabled(true); setTotpVerified(true); } if (data.tunnelMode) setTunnelMode(data.tunnelMode); if (data.tunnelDomain) setTunnelDomain(data.tunnelDomain); if (data.tunnelUrl) setTunnelUrl(data.tunnelUrl); // If user has existing handle, default to 'relay'; otherwise default to 'tunnel' if (!data.handle) setHandleChoice('tunnel'); // Snapshot the saved values so settings-mode per-screen Save buttons can detect changes // and so a single-screen save never clobbers another screen's saved value. setSettingsOrig({ userName: data.userName || '', botName: (data.handle && data.handle.username) || data.agentName || '', provider: data.provider || '', model: data.model || '', whisperEnabled: !!data.whisperEnabled, whisperKey: data.whisperKey || '', }); prefillDone.current = true; // Restore TOTP setup state if returning from authenticator app if (!data.totpEnabled) restoreTotpState(); }) .catch(() => { prefillDone.current = true; }); }, []); // Detect access method on mount useEffect(() => { setAccessMethod(detectAccessMethod(window.location.hostname)); }, []); // Mobile detection for TOTP setup useEffect(() => { setIsMobileDevice(window.matchMedia('(max-width: 768px)').matches || 'ontouchstart' in window); }, []); // Check if Claude is already authenticated when selecting Anthropic useEffect(() => { if (provider !== 'anthropic' || authState.anthropic === 'connected') return; fetch('/api/auth/claude/status') .then((r) => r.json()) .then((data) => { if (data.authenticated) setAuthState((s) => ({ ...s, anthropic: 'connected' })); }) .catch(() => {}); }, [provider]); // Check if Codex is already authenticated when selecting OpenAI useEffect(() => { if (provider !== 'openai' || authState.openai === 'connected') return; fetch('/api/auth/codex/status') .then((r) => r.json()) .then((data) => { if (data.authenticated) setAuthState((s) => ({ ...s, openai: 'connected' })); }) .catch(() => {}); }, [provider]); // Handle availability check (debounced, per-tier) // Skip when the current botName matches the existing registered handle useEffect(() => { if (handleDebounce.current) clearTimeout(handleDebounce.current); // Skip reset if this is the initial pre-fill setting the existing handle if (!prefillDone.current) return; // Private network mode — no handle registration needed if (tunnelMode === 'off') return; // Don't reset state if this is the already-registered handle if (existingHandle && registered && botName === existingHandle.username) { return; } setHandleStatus(null); setHandleError(''); setTierAvailability({}); setTierReserved({}); setShowClaimInput(false); setClaimCode(''); setClaimError(''); setRegistered(false); setRegisteredUrl(''); const trimmed = botName.trim(); if (!trimmed) return; if (trimmed.length < 3) { setHandleStatus('invalid'); setHandleError('At least 3 characters'); return; } setHandleStatus('checking'); handleDebounce.current = setTimeout(async () => { try { const res = await fetch(`/api/handle/check/${encodeURIComponent(trimmed)}`); const data = await res.json(); if (!data.valid) { setHandleStatus('invalid'); setHandleError(data.error); } else { const avail: Record = {}; const reserved: Record = {}; for (const h of data.handles) { avail[h.tier] = h.available; if (h.reserved) reserved[h.tier] = true; } setTierAvailability(avail); setTierReserved(reserved); setHandleStatus('ready'); // Reset selection — user must explicitly choose setSelectedTier(''); } } catch { setHandleStatus(null); } }, 400); return () => { if (handleDebounce.current) clearTimeout(handleDebounce.current); }; }, [botName]); // Re-check availability when user returns from external purchase page useEffect(() => { if (!botName || botName.length < 3 || handleStatus !== 'ready') return; const onFocus = async () => { try { const res = await fetch(`/api/handle/check/${encodeURIComponent(botName.trim())}`); const data = await res.json(); if (data.valid) { const avail: Record = {}; const reserved: Record = {}; for (const h of data.handles) { avail[h.tier] = h.available; if (h.reserved) reserved[h.tier] = true; } setTierAvailability(avail); setTierReserved(reserved); } } catch {} }; window.addEventListener('focus', onFocus); return () => window.removeEventListener('focus', onFocus); }, [botName, handleStatus]); const onBotNameInput = (val: string) => { setBotName(val.toLowerCase().replace(/[^a-z0-9-]/g, '')); }; const onClaimHandle = async () => { if (!botName || handleStatus !== 'ready' || !tierAvailability[selectedTier]) return; setRegistering(true); try { const res = await fetch('/api/handle/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: botName, tier: selectedTier }), }); const data = await res.json(); if (data.ok) { setRegistered(true); setRegisteredUrl(data.url); } else { setHandleError(data.error || 'Registration failed'); setHandleStatus('invalid'); } } catch { setHandleError('Could not reach server'); setHandleStatus('invalid'); } finally { setRegistering(false); } }; const onChangeHandle = async () => { if (!botName || handleStatus !== 'ready' || !tierAvailability[selectedTier]) return; setChangingHandle(true); try { const res = await fetch('/api/handle/change', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: botName, tier: selectedTier }), }); const data = await res.json(); if (data.ok) { setRegistered(true); setRegisteredUrl(data.url); setExistingHandle({ username: botName, tier: selectedTier, url: data.url }); setShowChangeConfirm(false); } else { setHandleError(data.error || 'Handle change failed'); setHandleStatus('invalid'); } } catch { setHandleError('Could not reach server'); setHandleStatus('invalid'); } finally { setChangingHandle(false); } }; const onClaimReserved = async () => { if (!botName || !claimCode) return; setClaiming(true); setClaimError(''); try { const res = await fetch('/api/handle/claim-reserved', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ handle: botName, hash: claimCode }), }); const data = await res.json(); if (data.ok) { setRegistered(true); setRegisteredUrl(data.url); setShowClaimInput(false); setClaimCode(''); setSelectedTier('premium'); setHandleChoice('relay'); } else { setClaimError(data.error || 'Invalid activation code'); } } catch { setClaimError('Could not reach server'); } finally { setClaiming(false); } }; const handleProviderChange = (id: string) => { if (provider === 'openai' && id !== 'openai') { if (codexOauthStarted) fetch('/api/auth/codex/cancel', { method: 'POST' }); if (codexDeviceState === 'pending') fetch('/api/auth/codex/device/cancel', { method: 'POST' }); } setProvider(id); setModel(id === 'openai' ? 'gpt-5.5:high' : ''); setOauthStarted(false); setAnthropicCode(''); setAnthropicError(undefined); setCodexOauthStarted(false); setCodexCode(''); setCodexDeviceState('idle'); setCodexUserCode(''); setCodexVerificationUrl(''); setCodexFlow('device'); setOpenaiError(undefined); // Pi flow cleanup is per-sub-provider; the load effect handles re-hydration. setPiError(undefined); }; /* ── Bloby (pi) handlers ── */ // Load the sub-provider catalog + saved status when the user picks Bloby useEffect(() => { if (provider !== 'pi') return; let cancelled = false; (async () => { try { const [provRes, statusRes] = await Promise.all([ fetch('/api/auth/pi/providers'), fetch('/api/auth/pi/status'), ]); const provData = await provRes.json(); const statusData = await statusRes.json(); if (cancelled) return; const list: PiSubProviderInfo[] = provData?.providers || []; setPiSubProviders(list); if (statusData?.configured) { setPiSubProvider(statusData.subProvider || ''); setPiModelId(statusData.modelId || ''); setPiBaseUrl(statusData.baseUrl || ''); setAuthState((s) => ({ ...s, pi: 'connected' })); setPiSavedStatus({ subProvider: statusData.subProvider, modelId: statusData.modelId, baseUrl: statusData.baseUrl }); setModel(`${statusData.subProvider}/${statusData.modelId || ''}`); } else if (!piSubProvider && list[0]) { setPiSubProvider(list[0].id); setPiBaseUrl(list[0].baseUrl || ''); setPiModelId(list[0].defaultModel || ''); } } catch (err: any) { if (!cancelled) setPiError(err?.message || 'Failed to load Bloby providers'); } })(); return () => { cancelled = true; }; }, [provider]); const selectedPiSub = piSubProviders.find((p) => p.id === piSubProvider); const choosePiSubProvider = (id: string) => { const next = piSubProviders.find((p) => p.id === id); setPiSubProvider(id); setPiBaseUrl(next?.baseUrl || ''); setPiModelId(next?.defaultModel || ''); setPiError(undefined); // Picking a new sub-provider invalidates the previously saved auth. if (authState.pi === 'connected') { setAuthState((s) => ({ ...s, pi: 'idle' })); setPiSavedStatus(null); } }; const handlePiConnect = async () => { if (!piSubProvider) return; setPiError(undefined); setPiConnecting(true); try { const payload = { subProvider: piSubProvider, apiKey: piApiKey.trim() || undefined, baseUrl: piBaseUrl.trim() || undefined, modelId: piModelId.trim() || undefined, }; const testRes = await fetch('/api/auth/pi/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const testData = await testRes.json(); if (!testData?.ok) { setPiError(testData?.error || 'Connection test failed'); return; } const saveRes = await fetch('/api/auth/pi/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const saveData = await saveRes.json(); if (!saveData?.ok) { setPiError(saveData?.error || 'Failed to save credentials'); return; } setAuthState((s) => ({ ...s, pi: 'connected' })); setPiSavedStatus(saveData.status || null); setModel(`${piSubProvider}/${piModelId || selectedPiSub?.defaultModel || ''}`); setPiApiKey(''); } catch (err: any) { setPiError(err?.message || 'Connection failed'); } finally { setPiConnecting(false); } }; const handlePiDisconnect = async () => { try { await fetch('/api/auth/pi', { method: 'DELETE' }); } catch {} setAuthState((s) => ({ ...s, pi: 'idle' })); setPiSavedStatus(null); setModel(''); }; /* ── Auth handlers: Anthropic/Claude ── */ const openExternal = (url: string) => { const isStandalone = window.matchMedia('(display-mode: standalone)').matches || (navigator as any).standalone === true; if (isStandalone) { // iOS PWA standalone mode blocks window.open — use a temp anchor with target _blank const a = document.createElement('a'); a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer'; document.body.appendChild(a); a.click(); document.body.removeChild(a); } else { window.open(url, '_blank', 'noopener,noreferrer'); } }; const handleAnthropicAuth = async () => { setAnthropicError(undefined); try { const res = await fetch('/api/auth/claude/start', { method: 'POST' }); const data = await res.json(); if (data.success && data.authUrl) { openExternal(data.authUrl); setOauthStarted(true); } else { setAnthropicError(data.error || 'Failed to start authentication'); } } catch (err: any) { setAnthropicError(err.message); } }; const handleAnthropicConnect = async () => { if (!anthropicCode.trim()) return; setIsExchanging(true); setAnthropicError(undefined); try { const res = await fetch('/api/auth/claude/exchange', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: anthropicCode.trim() }), }); const data = await res.json(); if (data.success) { setAuthState((s) => ({ ...s, anthropic: 'connected' })); } else { setAnthropicError(data.error || 'Code exchange failed'); } } catch (err: any) { setAnthropicError(err.message); } finally { setIsExchanging(false); } }; const handleAnthropicPaste = async () => { try { const text = await navigator.clipboard.readText(); if (text) setAnthropicCode(text.trim()); } catch { /* clipboard denied */ } }; const handleAnthropicCheckAuth = async () => { setAnthropicChecking(true); setAnthropicError(undefined); try { const res = await fetch('/api/auth/claude/status'); const data = await res.json(); if (data.authenticated) { setAuthState((s) => ({ ...s, anthropic: 'connected' })); } else { setAnthropicError('No active session found. Please authenticate first.'); } } catch {} finally { setAnthropicChecking(false); } }; /* ── Auth handlers: OpenAI/Codex (device-code flow — default) ── */ const handleOpenAIDeviceStart = async () => { setOpenaiError(undefined); setCodexDeviceStarting(true); setCodexDeviceState('pending'); try { const res = await fetch('/api/auth/codex/device/start', { method: 'POST' }); const data = await res.json(); if (data.success) { setCodexUserCode(data.userCode || ''); setCodexVerificationUrl(data.verificationUrl || ''); if (data.verificationUrl) openExternal(data.verificationUrl); } else { setCodexDeviceState('error'); setOpenaiError(data.error || 'Failed to start device-code login'); } } catch (err: any) { setCodexDeviceState('error'); setOpenaiError(err.message); } finally { setCodexDeviceStarting(false); } }; const handleOpenAIDeviceCancel = async () => { try { await fetch('/api/auth/codex/device/cancel', { method: 'POST' }); } catch {} setCodexDeviceState('idle'); setCodexUserCode(''); setCodexVerificationUrl(''); }; const handleCopyUserCode = async () => { try { await navigator.clipboard.writeText(codexUserCode); setCodexCodeCopied(true); setTimeout(() => setCodexCodeCopied(false), 1500); } catch { /* clipboard denied */ } }; // Poll device-code status while pending useEffect(() => { if (codexDeviceState !== 'pending' || !codexUserCode) return; const interval = setInterval(async () => { try { const res = await fetch('/api/auth/codex/device/status'); const data = await res.json(); if (data.state === 'success') { setCodexDeviceState('success'); setAuthState((s) => ({ ...s, openai: 'connected' })); } else if (data.state === 'error') { setCodexDeviceState('error'); setOpenaiError(data.error || 'Device-code login failed'); } } catch {} }, 2000); return () => clearInterval(interval); }, [codexDeviceState, codexUserCode]); /* ── Auth handlers: OpenAI/Codex (paste-back flow — fallback) ── */ const handleOpenAIAuth = async () => { setOpenaiError(undefined); try { const res = await fetch('/api/auth/codex/start', { method: 'POST' }); const data = await res.json(); if (data.success && data.authUrl) { openExternal(data.authUrl); setCodexOauthStarted(true); } else { setOpenaiError(data.error || 'Failed to start authentication'); } } catch (err: any) { setOpenaiError(err.message); } }; const handleOpenAIConnect = async () => { if (!codexCode.trim()) return; setCodexExchanging(true); setOpenaiError(undefined); try { const res = await fetch('/api/auth/codex/exchange', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: codexCode.trim() }), }); const data = await res.json(); if (data.success) { setAuthState((s) => ({ ...s, openai: 'connected' })); setCodexCode(''); } else { setOpenaiError(data.error || 'Code exchange failed'); } } catch (err: any) { setOpenaiError(err.message); } finally { setCodexExchanging(false); } }; const handleOpenAIPaste = async () => { try { const text = await navigator.clipboard.readText(); if (text) setCodexCode(text.trim()); } catch { /* clipboard denied */ } }; const handleOpenAICheckAuth = async () => { setCodexChecking(true); setOpenaiError(undefined); try { const res = await fetch('/api/auth/codex/status'); const data = await res.json(); if (data.authenticated) { setAuthState((s) => ({ ...s, openai: 'connected' })); } else { setOpenaiError('No active session found. Please authenticate first.'); } } catch {} finally { setCodexChecking(false); } }; /* ── Navigation ── */ // Steps: 0=Welcome, 1=Name, 2=Bot name + Handle, 3=Password, 4=Provider, 5=Whisper+Complete, 6=All Set (initial only) const portalPassMatch = portalPass === portalPassConfirm; const portalValid = portalPass.length >= 6 && portalPassMatch; // When portal exists: can continue if no password fields touched, or old pass verified + new pass valid const portalCanContinue = portalExists ? (portalPass.length === 0 || (portalOldPassVerified && portalValid)) : portalValid; const canNext = (() => { switch (step) { case 0: return true; case 1: return userName.trim().length > 0; case 2: { if (showTailscaleSwitch || showReEnableTunnel) return false; if (tunnelMode === 'off') return botName.trim().length >= 3; if (tunnelMode === 'named') return botName.trim().length >= 3; if (handleChoice === 'tunnel') return botName.trim().length >= 3; return registered; } case 3: { // Block "Continue" while in a TOTP sub-phase if (step3Phase !== 'password') return false; if (!portalCanContinue) return false; if (totpEnabled && !totpVerified) return false; return true; } case 4: return !!(provider && model && isConnected); case 5: return true; case 6: return true; // Environment Variables (settings) — self-saving case 7: return true; // Pulse & Crons (settings) — self-saving default: return false; } })(); // Sequential advance is onboarding-only. Settings mode is hub-and-spoke: the user navigates via // the header dropdown / "Go to" menu and each screen saves itself, so there is no "Continue". const next = () => { if (isInitialSetup && canNext && step < TOTAL_STEPS - 1) setStep((s) => s + 1); }; const back = () => { if (step > 0) setStep((s) => s - 1); }; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter' && canNext) next(); }; // ── Settings mode: per-screen change detection + save ── // Only the currently-shown settings screen reports "dirty"; the footer Save appears only then. const settingsScreenDirty = (() => { if (isInitialSetup) return false; switch (step) { case 1: return userName.trim() !== settingsOrig.userName; case 2: return botName.trim() !== settingsOrig.botName; case 3: return step3Phase === 'password' && portalPass.length > 0 && portalValid && (portalExists ? portalOldPassVerified : true); case 4: return (provider !== settingsOrig.provider || model !== settingsOrig.model) && isConnected && !!model; case 5: return (whisperEnabled !== settingsOrig.whisperEnabled || (whisperEnabled && whisperKey !== settingsOrig.whisperKey)) && !(whisperEnabled && (!whisperKey.startsWith('sk-') || whisperKey.length < 20)); default: return false; // 0 = hub, 6 = env (self-saving) } })(); // Clear the transient saved/error banners whenever the user moves to another settings screen. useEffect(() => { setSettingsSaved(false); setSettingsSaveError(''); contentScrollRef.current?.scrollTo({ top: 0 }); }, [step]); const saveSettings = async () => { if (!settingsScreenDirty || settingsSaving) return; setSettingsSaving(true); setSettingsSaveError(''); setSettingsSaved(false); // Base the payload on the last-saved values, then override ONLY the active screen's fields — // so saving one screen never persists another screen's unsaved, in-progress edits. const payload: any = { userName: settingsOrig.userName, agentName: settingsOrig.botName || 'Bloby', provider: settingsOrig.provider, model: settingsOrig.model, apiKey: '', whisperEnabled: settingsOrig.whisperEnabled, whisperKey: settingsOrig.whisperEnabled ? settingsOrig.whisperKey : '', portalUser: portalUser.trim(), portalPass: '', }; switch (step) { case 1: payload.userName = userName.trim(); break; case 2: payload.agentName = botName.trim() || 'Bloby'; break; case 3: payload.portalPass = portalPass; break; case 4: payload.provider = provider; payload.model = model; break; case 5: payload.whisperEnabled = whisperEnabled; payload.whisperKey = whisperEnabled ? whisperKey : ''; break; } try { if (onSave) await onSave(payload); else await fetch('/api/onboard', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); // Advance the saved snapshot so the Save button hides (screen no longer dirty). setSettingsOrig((o) => { switch (step) { case 1: return { ...o, userName: userName.trim() }; case 2: return { ...o, botName: botName.trim() }; case 4: return { ...o, provider, model }; case 5: return { ...o, whisperEnabled, whisperKey }; default: return o; } }); if (step === 3) { // Password persisted — reset the change-password UI so it's no longer dirty. setPortalPass(''); setPortalPassConfirm(''); setPortalOldPass(''); setPortalOldPassVerified(false); setPortalChangeMode(false); setPortalExists(true); } setSettingsSaved(true); setSettingsSaving(false); } catch (err: any) { setSettingsSaveError(err.message || 'Save failed'); setSettingsSaving(false); } }; const handleComplete = async () => { setSaving(true); const payload = { userName: userName.trim(), agentName: botName.trim() || 'Bloby', provider, model, apiKey: '', whisperEnabled, whisperKey: whisperEnabled ? whisperKey : '', portalUser: portalUser.trim(), portalPass, }; try { if (onSave) { // Chat context: save via WebSocket (bypasses relay POST issues) await onSave(payload); } else { // Initial onboard: direct POST await fetch('/api/onboard', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); } if (isInitialSetup) { setSaving(false); setStep(6); } else { onComplete(); } } catch (err) { console.error('[OnboardWizard] Onboard failed:', err); setSaving(false); } }; /* ── Styles ── */ const inputCls = 'w-full bg-white/[0.05] border border-white/[0.08] text-white rounded-xl px-4 py-3 text-base outline-none input-glow placeholder:text-white/20 transition-all'; const inputSmCls = 'w-full bg-white/[0.03] border border-white/[0.08] text-white rounded-xl px-4 py-2.5 text-[13px] outline-none input-glow placeholder:text-white/20 transition-all'; return (
{/* Onboarding: step dots. Settings: a header with hub-back, screen jump, and close. */} {isInitialSetup ? (
{Array.from({ length: TOTAL_STEPS }, (_, i) => (
))}
) : (
{step === 0 ? ( Settings ) : ( )}
{step !== 0 && setStep(s)} />}
)} {/* Content — scrolls within the viewport-capped card so tall screens stay reachable. */}
{/* ── Step 0: Welcome (onboarding) ── */} {step === 0 && isInitialSetup && (

Welcome to Bloby

Let's set up your AI assistant in just a few steps.

)} {/* ── Step 0: Settings hub (settings re-run) ── */} {step === 0 && !isInitialSetup && (

Settings

What would you like to change?

({ id: String(s.step), label: s.label }))} value="" placeholder="Select a setting…" menuMaxPx={188} onChange={(id) => setStep(Number(id))} />
)} {/* ── Step 1: Your name ── */} {step === 1 && (

What's your name?

This is how your agent will address you.

setUserName(e.target.value)} onKeyDown={handleKeyDown} placeholder="Enter your name" autoFocus autoComplete="off" data-1p-ignore data-lpignore="true" className={inputCls + ' flex-1'} /> {isInitialSetup && ( )}
)} {/* ── Step 2: Name your bot + Claim handle ── */} {step === 2 && tunnelMode === 'off' && (

Agent Name & Access

Give your bot a name. This is used throughout the app as your bot's identity.

onBotNameInput(e.target.value)} maxLength={30} placeholder="your-bot-name" spellCheck={false} autoCapitalize="none" autoCorrect="off" autoComplete="off" data-1p-ignore data-lpignore="true" autoFocus className={inputCls + ' pr-10 font-mono'} />

Private network mode — your bot is only accessible via your local network or VPN. No public URL will be created.

{/* Access badge */} {!isInitialSetup && (
{isPrivateAccess(accessMethod) ? : } Accessing via {ACCESS_LABELS[accessMethod]}
)} {/* Re-enable tunnel option */} {!isInitialSetup && !showReEnableTunnel && ( )} {/* Re-enable confirmation */} {showReEnableTunnel && (

Enable public access?

This will start a Cloudflare tunnel, making your bot reachable from the internet. {existingHandle && ' Your handle will reconnect automatically.'}

{reEnableError && (

{reEnableError}

)}
)} {isInitialSetup && ( )}
)} {step === 2 && tunnelMode === 'named' && (

Agent Name & Access

This is your bot's identity. Your named tunnel domain is already configured.

onBotNameInput(e.target.value)} maxLength={30} placeholder="your-bot-name" spellCheck={false} autoCapitalize="none" autoCorrect="off" autoComplete="off" data-1p-ignore data-lpignore="true" autoFocus className={inputCls + ' pr-10 font-mono'} />
https://{tunnelDomain}
)} {step === 2 && tunnelMode === 'quick' && (

Agent Name & Access

This is your bot's name and permanent handle — access it from anywhere.

{/* Existing handle banner */} {existingHandle && registered && !showChangeConfirm && ( <>

Current handle

{registeredUrl}

{isInitialSetup && ( )}
)} {/* Change confirmation alert */} {showChangeConfirm && !registered && (

Changing your handle

Your current handle {existingHandle?.url} will be released and become available for others.

)} {/* Input + handle flow — shown for new claim or change flow */} {(!existingHandle || showChangeConfirm || !registered) && !(existingHandle && registered && !showChangeConfirm) && ( <>
onBotNameInput(e.target.value)} maxLength={30} placeholder="your-bot-name" spellCheck={false} autoCapitalize="none" autoCorrect="off" autoComplete="off" data-1p-ignore data-lpignore="true" autoFocus disabled={registered} className={inputCls + ' pr-10 font-mono' + (registered ? ' opacity-50' : '')} /> {handleStatus && botName.length > 0 && !registered && (
{handleStatus === 'checking' && (
)} {handleStatus === 'invalid' && (
)}
)}
{/* Status messages */} {handleStatus === 'invalid' && handleError && (

{handleError}

)} {/* Handle tier options — shown when name is valid */} {handleStatus === 'ready' && botName.length > 0 && !registered && (
{/* ── Free tier block ── */} {(() => { const freeAvail = tierAvailability['at']; const freeTaken = freeAvail === false; const freeSelected = handleChoice === 'relay' && selectedTier === 'at'; return ( ); })()} {/* ── Premium tier block ── */} {(() => { const premAvail = tierAvailability['premium']; const premTaken = premAvail === false; const premReserved = premTaken && tierReserved['premium']; return (
Premium $5
{premReserved ? 'Reserved' : premTaken ? 'Taken' : 'Available'}

bloby.bot/{botName}

{/* Premium available — purchase on website */} {premAvail && (

Purchase on bloby.bot

Purchase
)} {/* Premium taken but reserved by user — activation */} {premReserved && (
{!showClaimInput ? (

Is that yours?

) : (

Enter the 5-character code from your bloby.bot account

setClaimCode(e.target.value.trim())} maxLength={5} placeholder="e.g. a3Kx9" spellCheck={false} autoComplete="off" autoFocus className="flex-1 bg-white/[0.05] border border-white/[0.08] text-white rounded-lg px-3 py-2 text-[13px] font-mono outline-none focus:border-[#0069FE]/30 transition-colors placeholder:text-white/20 tracking-widest text-center" />
{claimError && (

{claimError}

)}
)}
)}
); })()} {/* No handle — use random tunnel URL */}
)} {/* Registered success (after claiming) */} {registered && (

Handle claimed!

{registeredUrl}

)} {/* Action button — grayed out until user picks an option */} {handleStatus === 'ready' && botName.length > 0 && !registered && ( )} {/* Continue after claim (onboarding only — settings is hub-and-spoke) */} {isInitialSetup && registered && ( )} {/* Cancel change */} {showChangeConfirm && !registered && ( )} )} {/* ── Tailscale-only switch (only when re-running wizard) ── */} {!isInitialSetup && ( <> {/* Access badge */}
{isPrivateAccess(accessMethod) ? : } Accessing via {ACCESS_LABELS[accessMethod]}
{/* Switch to private network only */} {!showTailscaleSwitch && ( )} {/* Confirmation flow */} {showTailscaleSwitch && (

Switch to private network only?

This will stop the Cloudflare tunnel and relay connection. Your bot will only be accessible via your private network.

{existingHandle && (

Your handle will be preserved and can be re-activated later.

)}
setTailscaleConfirmText(e.target.value)} placeholder="I confirm" spellCheck={false} autoFocus className={inputSmCls} />
{tailscaleSwitchError && (

{tailscaleSwitchError}

)}
)} )}
)} {/* ── Step 3: Password + 2FA (sub-phase flow) ── */} {step === 3 && (
{/* ── Phase: Password ── */} {step3Phase === 'password' && (

{portalExists ? 'Password & 2FA' : 'Set a password'}

{portalExists ? "Your Bloby Chat password is already set — you can keep it as-is or change it below." : "You'll need this password to access your Bloby Chat. Keep it safe — anyone with your URL will need it to log in."}

{/* ── Existing-password collapsed state: just a "Change password" pill ── */} {portalExists && !portalChangeMode && (

Bloby Chat Password

Already configured.

)} {/* ── Existing-password expanded: current password verify step ── */} {portalExists && portalChangeMode && (
{ setPortalOldPass(e.target.value); setPortalOldPassError(''); setPortalOldPassVerified(false); }} placeholder="Enter your current password" autoComplete="current-password" className={inputCls + ' flex-1'} /> {portalOldPass.length > 0 && !portalOldPassVerified && ( )} {portalOldPassVerified && (
)}
{portalOldPassError && (

{portalOldPassError}

)}
)} {/* ── New-password fields: first-time onboard OR verified change-mode ── */} {(!portalExists || (portalChangeMode && portalOldPassVerified)) && ( <>
setPortalPass(e.target.value)} placeholder="••••••••" autoComplete="new-password" autoFocus={!portalExists} onKeyDown={handleKeyDown} className={inputCls} /> {portalPass.length > 0 && portalPass.length < 6 && (

At least 6 characters

)}
setPortalPassConfirm(e.target.value)} placeholder="••••••••" autoComplete="new-password" onKeyDown={handleKeyDown} className={inputCls} /> {portalPassConfirm.length > 0 && !portalPassMatch && (

Passwords don't match

)}
)} {/* ── 2FA toggle card ── */}
{/* Inline disable flow */} {totpDisabling && (

Enter your current TOTP code to disable 2FA:

{ setTotpDisableCode(e.target.value.replace(/\D/g, '')); setTotpDisableError(''); }} placeholder="000000" className={inputSmCls + ' flex-1 tracking-[0.3em] text-center font-mono'} />
{totpDisableError &&

{totpDisableError}

}
)} {totpError && step3Phase === 'password' && (

{totpError}

)}
{isInitialSetup && ( )}
)} {/* ── Phase: TOTP Setup (QR + verify) ── */} {step3Phase === 'totp-setup' && (

Set up 2FA

{totpError && (

{totpError}

)} {totpQrUri && ( <> {/* Desktop: horizontal QR + instructions | Mobile: authenticator link */} {isMobileDevice ? (

Add Bloby to your authenticator app, then enter the 6-digit code below to confirm.

{/* Primary: copy secret key (doesn't leave the app) */}

Paste it in your authenticator app → Add account → Enter key

{/* Secondary: deep link (saves state before leaving) */}
saveTotpState()} className="w-full mt-3 py-2.5 text-[13px] text-white/30 hover:text-white/50 flex items-center justify-center gap-1.5 transition-colors" > Or open directly in authenticator
) : (
{/* QR Code — compact */}
TOTP QR Code
{/* Instructions */}

Scan this QR code with your authenticator app.

Google Authenticator, Authy, 1Password, or any TOTP app.

)} {/* Verification input */}
{ setTotpCode(e.target.value.replace(/\D/g, '')); setTotpError(''); }} placeholder="000000" autoFocus className={inputCls + ' tracking-[0.3em] text-center font-mono flex-1'} />
)} )} {/* ── Phase: Recovery codes ── */} {step3Phase === 'recovery' && (

2FA enabled

Save your recovery codes

If you lose your authenticator app, you can use one of these codes to sign in. Each code works once. Store them somewhere safe.

{recoveryCodes.map((code, i) => (
{code}
))}
)}
)} {/* ── Step 4: Provider + Auth + Model ── */} {step === 4 && (

Choose your AI provider

Pick one provider to power your bot, authenticate, and select a model.

{/* Provider cards */}
{PROVIDERS.map((p) => ( ))}
{/* ── Auth flow: Pi (bring your own model) ── */} {provider === 'pi' && (
{authState.pi === 'connected' && piSavedStatus ? (

Connected — {piSubProviders.find((p) => p.id === piSavedStatus.subProvider)?.name || piSavedStatus.subProvider} {piSavedStatus.modelId ? <> · {piSavedStatus.modelId} : null}

) : ( <> {/* Two-column compact row: provider dropdown + model picker */}
({ id: sp.id, label: sp.name }))} value={piSubProvider} onChange={choosePiSubProvider} />
{selectedPiSub && Array.isArray(selectedPiSub.models) ? ( ) : ( setPiModelId(e.target.value)} placeholder={selectedPiSub?.defaultModel || 'model-id'} className={inputSmCls + ' font-mono'} /> )}
{selectedPiSub?.needsBaseUrl && ( setPiBaseUrl(e.target.value)} placeholder={selectedPiSub.baseUrl || 'https://example.com/v1'} className={inputSmCls + ' font-mono'} /> )} {selectedPiSub?.needsApiKey && (
setPiApiKey(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handlePiConnect()} placeholder="API key…" className={inputSmCls + ' pr-16 font-mono'} />
{selectedPiSub.apiKeyUrl && ( )}
)} {piError && (

{piError}

)} )}
)} {/* ── Auth flow: Bloby (coming soon placeholder) ── */} {provider === 'bloby' && (

Bloby (managed) is on the way. Sign in with Google once it ships and your bot will be hosted, updated, and billed by us — no API keys to manage.

Not available yet.

)} {/* ── Auth flow: Anthropic ── */} {provider === 'anthropic' && (
{isConnected && (

Connected — Anthropic subscription is active.

)} {!isConnected && ( <> {anthropicError && (

{anthropicError}

)}
{[ 'Click the button below to open Anthropic\'s login page', 'Sign in with your Anthropic account — a code will be generated', 'Copy the code and paste it in the field below', ].map((text, i) => (
{i + 1}

{text}

))}
setAnthropicCode(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleAnthropicConnect()} placeholder="Paste your code here..." className={inputSmCls + ' pr-10 font-mono'} />
)}
)} {/* ── Auth flow: OpenAI ── */} {provider === 'openai' && (
{isConnected && (

Connected — ChatGPT subscription is active.

)} {!isConnected && ( <> {openaiError && (

{openaiError}

)} {/* ── Device-code flow (default) ── */} {codexFlow === 'device' && codexDeviceState !== 'pending' && ( <>
{[ 'Click below — we\'ll open auth.openai.com/codex/device for you', 'Sign in with your ChatGPT Plus or Pro account', 'Type the one-time code shown here on that page', ].map((text, i) => (
{i + 1}

{text}

))}
)} {codexFlow === 'device' && codexDeviceState === 'pending' && codexUserCode && (

1. Open this URL in any browser

2. Enter this code

Waiting for you to approve...
)} {/* ── Paste-back flow (fallback) ── */} {codexFlow === 'paste' && ( <>
{[ 'Click below to open ChatGPT sign-in', 'Sign in with your ChatGPT Plus or Pro account', 'Your browser will say "site can\'t be reached" — that\'s expected. Copy the FULL URL from the address bar and paste it below.', ].map((text, i) => (
{i + 1}

{text}

))}
setCodexCode(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleOpenAIConnect()} placeholder="Paste callback URL or code here..." className={inputSmCls + ' pr-10 font-mono'} />
)} {/* ── Flow toggle + already-authenticated ── */} {codexDeviceState !== 'pending' && (
)} )}
)} {/* ── Model dropdown (after auth) — pi flow renders its own model picker inside its block ── */} {isConnected && provider !== 'pi' && ( <>
)} {isInitialSetup && isConnected && ( )}
)} {/* ── Step 5: Voice Messages + Complete ── */} {step === 5 && (

Voice Messages

Voice input works out of the box using your browser's built-in speech recognition. For better accuracy, you can optionally enable OpenAI Whisper.

{/* Browser Speech Recognition card */}
Browser Speech Recognition
Built-in voice input — no setup needed. Works in Chrome, Edge, and Safari.
{/* Whisper upgrade toggle */} {whisperEnabled && (
setWhisperKey(e.target.value.trim())} placeholder="sk-..." autoComplete="off" className={inputCls + ' font-mono text-[13px]'} /> {whisperKey.length > 0 && !whisperKey.startsWith('sk-') && (

Key should start with sk-

)} {whisperKey.length > 0 && whisperKey.startsWith('sk-') && whisperKey.length < 20 && (

Key looks too short

)}

Whisper provides more accurate transcription and works in all browsers including Firefox.

)} {isInitialSetup && ( )} {!whisperEnabled && (

Voice input is active using your browser's built-in speech recognition.

)}
)} {/* ── Step 6: All Set (initial onboard only) ── */} {step === 6 && isInitialSetup && (() => { const isPrivate = tunnelMode === 'off'; const finalUrl = (() => { if (isPrivate) return window.location.origin; if (tunnelMode === 'named') return `https://${tunnelDomain}`; if (handleChoice === 'relay' && registeredUrl) return registeredUrl; return tunnelUrl || `http://localhost:${3000}`; })(); const finalUrlFull = finalUrl.startsWith('http') ? finalUrl : `https://${finalUrl}`; const descriptionText = isPrivate ? 'Your agent is running on your private network. Access it from any device on your local network or VPN.' : tunnelMode === 'named' ? 'Access your agent at your custom domain.' : handleChoice === 'relay' && registeredUrl ? 'Your agent is live and ready. From now on, access it using your custom URL below.' : 'Your agent is live and ready. Your tunnel URL is shown below. Note: this URL changes on restart.'; return (

All Set!

{descriptionText}

{/* URL */}
{finalUrl}
{/* Password display with eye toggle */}
{showPassAllSet ? portalPass : '\u2022'.repeat(Math.max(portalPass.length, 8))}
{/* Terms & Privacy checkbox */} {/* Redirect / done button */}

{isPrivate ? 'Access from any device on your network using the URL above.' : `You'll be redirected to your ${tunnelMode === 'named' ? 'custom domain' : handleChoice === 'relay' ? 'custom URL' : 'tunnel URL'}.` }

); })()} {/* ── Step 6: Environment Variables (settings re-run only) ── */} {step === 6 && !isInitialSetup && } {/* ── Step 7: Pulse & Crons (settings re-run only) ── */} {step === 7 && !isInitialSetup && }
{/* Back link — onboarding only; settings mode navigates via the header. */} {isInitialSetup && step > 0 && step < TOTAL_STEPS - 1 && (
)} {/* Settings mode: per-screen Save — sticky at the bottom, shown only when the current screen has changes. (Env step 6 / Pulse & Crons step 7 save themselves inside.) */} {!isInitialSetup && step >= 1 && step <= 5 && (settingsScreenDirty || settingsSaving || settingsSaved || settingsSaveError) && (
{settingsSaveError && (

{settingsSaveError}

)} {settingsSaved && !settingsScreenDirty ? (
Saved
) : ( )}
)}
); }