bloby-bot 0.58.0 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { useState, useRef, useEffect, type KeyboardEvent, type MutableRefObject } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
- import { ArrowRight, ArrowLeft, LoaderCircle, ExternalLink, ClipboardPaste, RefreshCw, Check, ChevronDown, Mic, Eye, EyeOff, Shield, ShieldCheck, ShieldOff, Copy, Smartphone, Globe, Wifi, WifiOff, TriangleAlert, X, Plus, KeyRound } from 'lucide-react';
3
+ 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';
4
4
  import { motion, AnimatePresence } from 'framer-motion';
5
5
  import { authFetch } from './src/lib/auth';
6
6
 
@@ -168,6 +168,7 @@ const SETTINGS_SCREENS: { step: number; label: string }[] = [
168
168
  { step: 4, label: 'AI Provider' },
169
169
  { step: 5, label: 'Voice Messages' },
170
170
  { step: 6, label: 'Environment Variables' },
171
+ { step: 7, label: 'Pulse & Crons' },
171
172
  ];
172
173
 
173
174
  // Compact "Go to" dropdown shown in the settings header so the user can jump to any screen.
@@ -521,6 +522,524 @@ function EnvSettings({ cacheRef }: { cacheRef: MutableRefObject<EnvCache | null>
521
522
  );
522
523
  }
523
524
 
525
+ /* ── Settings mode: Pulse & Crons screen ── */
526
+
527
+ interface PulseConfig {
528
+ enabled: boolean;
529
+ intervalMinutes: number;
530
+ quietHours: { start: string; end: string };
531
+ }
532
+
533
+ interface CronView {
534
+ id: string;
535
+ schedule: string;
536
+ task: string;
537
+ enabled: boolean;
538
+ oneShot?: boolean;
539
+ paused?: boolean;
540
+ hasTaskFile: boolean;
541
+ nextRun: string | null; // ISO
542
+ description: string; // humanized schedule
543
+ }
544
+
545
+ interface PulseCronCache { pulse: PulseConfig; original: PulseConfig | null; crons: CronView[] }
546
+
547
+ const DEFAULT_PULSE: PulseConfig = { enabled: false, intervalMinutes: 60, quietHours: { start: '22:00', end: '07:00' } };
548
+
549
+ // 8 slider stops; the last (-1) is the "Custom" sentinel.
550
+ const PULSE_STOPS: { mins: number; label: string }[] = [
551
+ { mins: 10, label: '10m' }, { mins: 30, label: '30m' }, { mins: 60, label: '1h' },
552
+ { mins: 120, label: '2h' }, { mins: 180, label: '3h' }, { mins: 360, label: '6h' },
553
+ { mins: 720, label: '12h' }, { mins: -1, label: 'Custom' },
554
+ ];
555
+ const PULSE_CUSTOM_INDEX = PULSE_STOPS.length - 1;
556
+ const PULSE_TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
557
+
558
+ function pulseIndexForMinutes(mins: number): number {
559
+ const i = PULSE_STOPS.findIndex((s) => s.mins === mins);
560
+ return i === -1 ? PULSE_CUSTOM_INDEX : i;
561
+ }
562
+
563
+ function humanizeInterval(mins: number): string {
564
+ if (!Number.isFinite(mins) || mins <= 0) return 'Every —';
565
+ if (mins % 60 === 0) { const h = mins / 60; return `Every ${h} hour${h === 1 ? '' : 's'}`; }
566
+ if (mins < 60) return `Every ${mins} minute${mins === 1 ? '' : 's'}`;
567
+ return `Every ${Math.floor(mins / 60)}h ${mins % 60}m`;
568
+ }
569
+
570
+ // nextRun ISO → short human string ("in 12m", "Today 3:00 PM", "Tomorrow 9:00 AM", "Jun 9, 3:00 PM").
571
+ function formatNextRun(iso: string | null): string | null {
572
+ if (!iso) return null;
573
+ const d = new Date(iso);
574
+ if (isNaN(d.getTime())) return null;
575
+ const now = new Date();
576
+ const diffMs = d.getTime() - now.getTime();
577
+ const time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
578
+ if (diffMs > 0 && diffMs < 60 * 60 * 1000) return `in ${Math.max(1, Math.round(diffMs / 60000))}m`;
579
+ if (diffMs > 0 && diffMs < 6 * 60 * 60 * 1000) return `in ${Math.round(diffMs / 3_600_000)}h`;
580
+ const startOfDay = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate());
581
+ const dayDiff = Math.round((startOfDay(d).getTime() - startOfDay(now).getTime()) / 86_400_000);
582
+ if (dayDiff === 0) return `Today ${time}`;
583
+ if (dayDiff === 1) return `Tomorrow ${time}`;
584
+ if (dayDiff > 1 && dayDiff < 7) return `${d.toLocaleDateString([], { weekday: 'long' })} ${time}`;
585
+ return `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })}, ${time}`;
586
+ }
587
+
588
+ function PulseSection({ pulse, original, setPulse, onPulseSaved }: {
589
+ pulse: PulseConfig;
590
+ original: PulseConfig | null;
591
+ setPulse: (next: PulseConfig) => void;
592
+ onPulseSaved: (saved: PulseConfig) => void;
593
+ }) {
594
+ const [saving, setSaving] = useState(false);
595
+ const [saved, setSaved] = useState(false);
596
+ const [saveError, setSaveError] = useState('');
597
+ // Explicit "Custom" mode — NOT derived from the value, otherwise typing a value that happens to
598
+ // equal a preset (e.g. 60) would snap the slider and unmount the input mid-keystroke. Seeded from
599
+ // whether the loaded interval is a non-preset value.
600
+ const [customMode, setCustomMode] = useState(pulseIndexForMinutes(pulse.intervalMinutes) === PULSE_CUSTOM_INDEX);
601
+
602
+ const presetIdx = pulseIndexForMinutes(pulse.intervalMinutes);
603
+ const onCustom = customMode;
604
+ const sliderIdx = customMode ? PULSE_CUSTOM_INDEX : presetIdx;
605
+ const quietValid = PULSE_TIME_RE.test(pulse.quietHours.start) && PULSE_TIME_RE.test(pulse.quietHours.end);
606
+ const intervalValid = Number.isFinite(pulse.intervalMinutes) && pulse.intervalMinutes >= 1 && pulse.intervalMinutes <= 1440;
607
+ const dirty = original != null && (
608
+ pulse.enabled !== original.enabled ||
609
+ pulse.intervalMinutes !== original.intervalMinutes ||
610
+ pulse.quietHours.start !== original.quietHours.start ||
611
+ pulse.quietHours.end !== original.quietHours.end
612
+ );
613
+ const canSave = dirty && intervalValid && quietValid && !saving;
614
+
615
+ const patch = (p: Partial<PulseConfig>) => {
616
+ if (saved) setSaved(false);
617
+ if (saveError) setSaveError('');
618
+ setPulse({ ...pulse, ...p });
619
+ };
620
+
621
+ const onSlider = (idx: number) => {
622
+ if (idx === PULSE_CUSTOM_INDEX) {
623
+ setCustomMode(true);
624
+ // Keep the current value when already custom; seed 45 only when entering from a preset.
625
+ const seed = (customMode || presetIdx === PULSE_CUSTOM_INDEX) ? pulse.intervalMinutes : 45;
626
+ patch({ intervalMinutes: seed });
627
+ } else {
628
+ setCustomMode(false);
629
+ patch({ intervalMinutes: PULSE_STOPS[idx].mins });
630
+ }
631
+ };
632
+
633
+ const handleSave = async () => {
634
+ if (!canSave) return;
635
+ setSaving(true); setSaveError(''); setSaved(false);
636
+ try {
637
+ const res = await authFetch('/api/pulse', {
638
+ method: 'POST',
639
+ headers: { 'Content-Type': 'application/json' },
640
+ body: JSON.stringify({ enabled: pulse.enabled, intervalMinutes: pulse.intervalMinutes, quietHours: pulse.quietHours }),
641
+ });
642
+ if (!res.ok) { const d = await res.json().catch(() => ({ error: 'Save failed' })); throw new Error(d.error || 'Save failed'); }
643
+ onPulseSaved(pulse);
644
+ setSaved(true);
645
+ setSaving(false);
646
+ } catch (err: any) {
647
+ setSaveError(err?.message || 'Save failed');
648
+ setSaving(false);
649
+ }
650
+ };
651
+
652
+ return (
653
+ <div>
654
+ <div className="flex items-center gap-3">
655
+ <div className="w-9 h-9 rounded-xl bg-[#0069FE]/10 flex items-center justify-center shrink-0">
656
+ <Activity className="h-[18px] w-[18px] text-[#0069FE]" />
657
+ </div>
658
+ <div className="flex-1 min-w-0">
659
+ <h2 className="text-[15px] font-bold text-white tracking-tight">Pulse</h2>
660
+ </div>
661
+ <button
662
+ type="button" role="switch" aria-checked={pulse.enabled} aria-label="Enable pulse"
663
+ onClick={() => patch({ enabled: !pulse.enabled })}
664
+ className={`w-10 h-[22px] rounded-full transition-colors duration-200 flex items-center px-0.5 shrink-0 ${pulse.enabled ? 'bg-gradient-brand' : 'bg-white/[0.08]'}`}
665
+ >
666
+ <div className={`w-[18px] h-[18px] rounded-full bg-white shadow-sm transition-transform duration-200 ${pulse.enabled ? 'translate-x-[18px]' : 'translate-x-0'}`} />
667
+ </button>
668
+ </div>
669
+
670
+ <p className="text-white/40 text-[13px] mt-2 leading-relaxed">
671
+ Pulse periodically wakes the agent inside the main session, letting it surface anything that needs attention without spamming you.
672
+ </p>
673
+
674
+ <div className={`mt-5 transition-opacity duration-200 ${pulse.enabled ? '' : 'opacity-50 pointer-events-none select-none'}`} aria-disabled={!pulse.enabled}>
675
+ <div className="flex items-center justify-between mb-2.5">
676
+ <span className="text-[12px] font-semibold text-white/70 uppercase tracking-wide">Frequency</span>
677
+ <span className="text-[11px] font-medium text-[#0069FE] bg-[#0069FE]/10 rounded-full px-2.5 py-1">
678
+ {onCustom ? 'Custom interval' : humanizeInterval(pulse.intervalMinutes)}
679
+ </span>
680
+ </div>
681
+
682
+ <input
683
+ type="range" min={0} max={PULSE_CUSTOM_INDEX} step={1} value={sliderIdx}
684
+ onChange={(e) => onSlider(Number(e.target.value))} aria-label="Pulse interval"
685
+ className="w-full h-1.5 appearance-none rounded-full bg-white/[0.08] accent-[#0069FE] cursor-pointer
686
+ [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4
687
+ [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white
688
+ [&::-webkit-slider-thumb]:shadow-[0_0_0_3px_rgba(0,105,254,0.35)] [&::-webkit-slider-thumb]:cursor-pointer
689
+ [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full
690
+ [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:shadow-[0_0_0_3px_rgba(0,105,254,0.35)]"
691
+ />
692
+
693
+ <div className="flex justify-between mt-2 px-[1px]">
694
+ {PULSE_STOPS.map((s, i) => (
695
+ <button
696
+ key={s.label} type="button" onClick={() => onSlider(i)}
697
+ className={`text-[10px] leading-none transition-colors ${i === sliderIdx ? 'text-[#0069FE] font-semibold' : 'text-white/30 hover:text-white/55'}`}
698
+ >
699
+ {s.label}
700
+ </button>
701
+ ))}
702
+ </div>
703
+
704
+ {onCustom && (
705
+ <div className="mt-3 flex items-center gap-2.5">
706
+ <input
707
+ type="number" min={1} max={1440}
708
+ value={Number.isFinite(pulse.intervalMinutes) ? pulse.intervalMinutes : ''}
709
+ onChange={(e) => { const n = parseInt(e.target.value, 10); patch({ intervalMinutes: Number.isFinite(n) ? n : NaN }); }}
710
+ placeholder="45"
711
+ 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"
712
+ />
713
+ <span className="text-[12px] text-white/40">minutes between pulses</span>
714
+ </div>
715
+ )}
716
+ {onCustom && !intervalValid && (
717
+ <p className="text-amber-400/80 text-[11px] mt-1.5">Enter a whole number of minutes (1–1440).</p>
718
+ )}
719
+
720
+ <div className="flex items-center gap-2 mt-6 mb-2.5">
721
+ <Moon className="h-3.5 w-3.5 text-[#0069FE]/70" />
722
+ <span className="text-[12px] font-semibold text-white/70 uppercase tracking-wide">Quiet hours</span>
723
+ </div>
724
+ <div className="flex items-center gap-3">
725
+ <div className="flex-1">
726
+ <label className="text-[11px] text-white/40 mb-1 block">Start</label>
727
+ <input
728
+ type="time" value={pulse.quietHours.start}
729
+ onChange={(e) => patch({ quietHours: { ...pulse.quietHours, start: e.target.value } })}
730
+ 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]"
731
+ />
732
+ </div>
733
+ <ArrowRight className="h-4 w-4 text-white/25 mt-5 shrink-0" />
734
+ <div className="flex-1">
735
+ <label className="text-[11px] text-white/40 mb-1 block">End</label>
736
+ <input
737
+ type="time" value={pulse.quietHours.end}
738
+ onChange={(e) => patch({ quietHours: { ...pulse.quietHours, end: e.target.value } })}
739
+ 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]"
740
+ />
741
+ </div>
742
+ </div>
743
+ <p className="text-white/35 text-[12px] mt-2 leading-relaxed">
744
+ No pulses fire between these times (your computer's local time). Spanning midnight is fine — e.g. 22:00 → 07:00.
745
+ </p>
746
+ </div>
747
+
748
+ {saved && !dirty && (
749
+ <div className="mt-4 flex items-center gap-2.5 bg-emerald-500/[0.06] border border-emerald-500/20 rounded-xl px-4 py-3">
750
+ <Check className="h-4 w-4 text-emerald-400 shrink-0" />
751
+ <p className="text-emerald-300/90 text-[12px]">Saved — the scheduler picks this up within a minute.</p>
752
+ </div>
753
+ )}
754
+ {saveError && (
755
+ <div className="mt-3 flex items-center gap-1.5 text-[12px] text-red-400">
756
+ <TriangleAlert className="h-3.5 w-3.5 shrink-0" />{saveError}
757
+ </div>
758
+ )}
759
+ {dirty && (
760
+ <button
761
+ type="button" onClick={handleSave} disabled={!canSave}
762
+ className="w-full mt-4 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40 disabled:cursor-not-allowed"
763
+ >
764
+ {saving ? (<><LoaderCircle className="h-4 w-4 animate-spin" />Saving...</>) : (<><Save className="h-4 w-4" />Save pulse settings</>)}
765
+ </button>
766
+ )}
767
+ </div>
768
+ );
769
+ }
770
+
771
+ function CronsSection({ crons, onChanged, onLocalUpdate }: {
772
+ crons: CronView[];
773
+ onChanged: () => void;
774
+ onLocalUpdate: (fn: (prev: CronView[]) => CronView[]) => void;
775
+ }) {
776
+ const [busy, setBusy] = useState<Record<string, 'pause' | 'delete' | undefined>>({});
777
+ const [confirmId, setConfirmId] = useState<string | null>(null);
778
+ const [downloading, setDownloading] = useState<Record<string, boolean>>({});
779
+ const [actionError, setActionError] = useState('');
780
+
781
+ const setRowBusy = (id: string, v: 'pause' | 'delete' | undefined) => setBusy((p) => ({ ...p, [id]: v }));
782
+
783
+ const handleTogglePause = async (c: CronView) => {
784
+ if (busy[c.id]) return;
785
+ setActionError(''); setRowBusy(c.id, 'pause');
786
+ try {
787
+ const res = await authFetch('/api/crons/pause', {
788
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
789
+ body: JSON.stringify({ id: c.id, paused: !c.paused }),
790
+ });
791
+ if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.error || 'Failed to update'); }
792
+ // Optimistic: reflect the change immediately so a failed background refetch can't leave a
793
+ // stale row; onChanged() then reconciles against the on-disk truth.
794
+ onLocalUpdate((prev) => prev.map((x) => (x.id === c.id ? { ...x, paused: !c.paused } : x)));
795
+ onChanged();
796
+ } catch (err: any) {
797
+ setActionError(err.message || 'Failed to update');
798
+ } finally {
799
+ setRowBusy(c.id, undefined);
800
+ }
801
+ };
802
+
803
+ const handleDelete = async (c: CronView) => {
804
+ if (busy[c.id]) return;
805
+ setActionError(''); setRowBusy(c.id, 'delete');
806
+ try {
807
+ const res = await authFetch('/api/crons/delete', {
808
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
809
+ body: JSON.stringify({ id: c.id }),
810
+ });
811
+ if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.error || 'Failed to delete'); }
812
+ setConfirmId(null);
813
+ onLocalUpdate((prev) => prev.filter((x) => x.id !== c.id));
814
+ onChanged();
815
+ } catch (err: any) {
816
+ setActionError(err.message || 'Failed to delete');
817
+ } finally {
818
+ setRowBusy(c.id, undefined);
819
+ }
820
+ };
821
+
822
+ // Authed download: the endpoint requires a Bearer token, so a plain <a href> would 401.
823
+ const handleDownloadTask = async (c: CronView) => {
824
+ if (downloading[c.id]) return;
825
+ setActionError(''); setDownloading((p) => ({ ...p, [c.id]: true }));
826
+ try {
827
+ const res = await authFetch(`/api/crons/task?id=${encodeURIComponent(c.id)}`);
828
+ if (!res.ok) throw new Error('Could not download task file');
829
+ const blob = await res.blob();
830
+ const url = URL.createObjectURL(blob);
831
+ const a = document.createElement('a');
832
+ a.href = url; a.download = `${c.id}.md`;
833
+ document.body.appendChild(a); a.click(); a.remove();
834
+ URL.revokeObjectURL(url);
835
+ } catch (err: any) {
836
+ setActionError(err.message || 'Download failed');
837
+ } finally {
838
+ setDownloading((p) => ({ ...p, [c.id]: false }));
839
+ }
840
+ };
841
+
842
+ return (
843
+ <div className="mt-8">
844
+ <div className="flex items-center gap-2">
845
+ <CalendarClock className="h-4 w-4 text-[#0069FE]/70 shrink-0" />
846
+ <h2 className="text-[15px] font-bold text-white tracking-tight">Crons</h2>
847
+ </div>
848
+ <p className="text-white/40 text-[13px] mt-1.5 leading-relaxed">
849
+ 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.
850
+ </p>
851
+ <div className="mt-2 flex items-start gap-1.5 text-white/30 text-[12px] leading-relaxed">
852
+ <Info className="h-3.5 w-3.5 mt-0.5 shrink-0" />
853
+ <span>To change or add a cron, just ask your agent. To delete or pause, use the buttons below.</span>
854
+ </div>
855
+
856
+ {actionError && (
857
+ <div className="mt-3 flex items-center gap-1.5 text-[12px] text-red-400">
858
+ <TriangleAlert className="h-3.5 w-3.5 shrink-0" />{actionError}
859
+ </div>
860
+ )}
861
+
862
+ <div className="mt-4 space-y-2.5">
863
+ {crons.length === 0 ? (
864
+ <div className="rounded-xl border border-white/[0.06] bg-white/[0.02] px-4 py-7 text-center">
865
+ <CalendarClock className="h-5 w-5 text-white/25 mx-auto mb-2" />
866
+ <p className="text-white/40 text-[13px]">No scheduled tasks yet.</p>
867
+ <p className="text-white/25 text-[12px] mt-1">Ask your agent to schedule something.</p>
868
+ </div>
869
+ ) : (
870
+ crons.map((c) => {
871
+ const paused = !!c.paused;
872
+ const disabled = c.enabled === false;
873
+ const parked = paused || disabled;
874
+ const rowBusy = busy[c.id];
875
+ const nextStr = formatNextRun(c.nextRun);
876
+ return (
877
+ <div key={c.id} className={`rounded-xl border p-3.5 transition-colors ${parked ? 'border-white/[0.05] bg-white/[0.015] opacity-60' : 'border-white/[0.06] bg-white/[0.02]'}`}>
878
+ <div className="flex items-start gap-3">
879
+ <div className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 ${c.oneShot ? 'bg-amber-500/10' : 'bg-[#0069FE]/10'}`}>
880
+ {c.oneShot ? <Zap className="h-[18px] w-[18px] text-amber-400" /> : <Clock className="h-[18px] w-[18px] text-[#0069FE]" />}
881
+ </div>
882
+ <div className="flex-1 min-w-0">
883
+ <div className="flex items-start justify-between gap-2">
884
+ <p className="text-[13px] text-white leading-snug">{c.task}</p>
885
+ <div className="flex items-center gap-1 shrink-0">
886
+ {c.oneShot && (
887
+ <span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 border border-amber-500/20 text-amber-300/90 text-[10px] font-medium px-2 py-0.5">
888
+ <Zap className="h-2.5 w-2.5" />One-time
889
+ </span>
890
+ )}
891
+ {paused && (
892
+ <span className="inline-flex items-center gap-1 rounded-full bg-white/[0.06] border border-white/[0.08] text-white/50 text-[10px] font-medium px-2 py-0.5">
893
+ <Pause className="h-2.5 w-2.5" />Paused
894
+ </span>
895
+ )}
896
+ {disabled && !paused && (
897
+ <span className="inline-flex items-center gap-1 rounded-full bg-white/[0.06] border border-white/[0.08] text-white/50 text-[10px] font-medium px-2 py-0.5">
898
+ Disabled
899
+ </span>
900
+ )}
901
+ </div>
902
+ </div>
903
+
904
+ <div className="mt-1.5 flex items-center gap-1.5 text-[12px] text-white/45">
905
+ <Clock className="h-3 w-3 shrink-0 text-white/30" />
906
+ <span className="truncate">{c.description}</span>
907
+ </div>
908
+
909
+ <div className="mt-1.5">
910
+ {parked ? (
911
+ <span className="text-[11px] text-white/30">{paused ? "Paused — won't run" : "Disabled — won't run"}</span>
912
+ ) : nextStr ? (
913
+ <span className="inline-flex items-center gap-1 rounded-md bg-[#0069FE]/[0.08] border border-[#0069FE]/15 text-[#4AA8FF] text-[11px] font-medium px-2 py-0.5">
914
+ Next: {nextStr}
915
+ </span>
916
+ ) : (
917
+ <span className="text-[11px] text-white/30">No upcoming run</span>
918
+ )}
919
+ </div>
920
+ </div>
921
+ </div>
922
+
923
+ <div className="mt-3 flex items-center justify-between gap-2">
924
+ {c.hasTaskFile ? (
925
+ <button
926
+ type="button" onClick={() => handleDownloadTask(c)} disabled={downloading[c.id]}
927
+ className="group inline-flex items-center gap-1.5 rounded-md bg-white/[0.04] hover:bg-white/[0.07] border border-white/[0.06] hover:border-white/[0.12] text-white/50 hover:text-white/80 text-[11px] font-mono px-2 py-1 transition-colors disabled:opacity-50"
928
+ title="Download task instructions"
929
+ >
930
+ {downloading[c.id] ? <LoaderCircle className="h-3 w-3 animate-spin" /> : <FileText className="h-3 w-3 shrink-0" />}
931
+ {c.id}.md
932
+ <Download className="h-3 w-3 opacity-0 group-hover:opacity-100 transition-opacity" />
933
+ </button>
934
+ ) : (<span />)}
935
+
936
+ {confirmId === c.id ? (
937
+ <div className="flex items-center gap-1.5">
938
+ <span className="text-[11px] text-white/50 mr-0.5">Delete?</span>
939
+ <button
940
+ type="button" onClick={() => handleDelete(c)} disabled={rowBusy === 'delete'}
941
+ className="inline-flex items-center gap-1 rounded-md bg-red-500/15 hover:bg-red-500/25 border border-red-500/30 text-red-300 text-[11px] font-medium px-2.5 py-1 transition-colors disabled:opacity-50"
942
+ >
943
+ {rowBusy === 'delete' ? <LoaderCircle className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
944
+ Delete
945
+ </button>
946
+ <button
947
+ type="button" onClick={() => setConfirmId(null)} disabled={rowBusy === 'delete'}
948
+ className="rounded-md text-white/40 hover:text-white/70 text-[11px] px-2 py-1 transition-colors"
949
+ >
950
+ Cancel
951
+ </button>
952
+ </div>
953
+ ) : (
954
+ <div className="flex items-center gap-1">
955
+ <button
956
+ type="button" onClick={() => handleTogglePause(c)} disabled={!!rowBusy}
957
+ className="inline-flex items-center gap-1 rounded-md text-white/45 hover:text-white/80 hover:bg-white/[0.04] text-[11px] font-medium px-2 py-1 transition-colors disabled:opacity-50"
958
+ >
959
+ {rowBusy === 'pause' ? <LoaderCircle className="h-3 w-3 animate-spin" /> : paused ? <Play className="h-3 w-3" /> : <Pause className="h-3 w-3" />}
960
+ {paused ? 'Resume' : 'Pause'}
961
+ </button>
962
+ <button
963
+ type="button" onClick={() => setConfirmId(c.id)} disabled={!!rowBusy}
964
+ className="inline-flex items-center gap-1 rounded-md text-white/30 hover:text-red-400 hover:bg-red-500/[0.06] text-[11px] font-medium px-2 py-1 transition-colors disabled:opacity-50"
965
+ >
966
+ <Trash2 className="h-3 w-3" />Delete
967
+ </button>
968
+ </div>
969
+ )}
970
+ </div>
971
+ </div>
972
+ );
973
+ })
974
+ )}
975
+ </div>
976
+ </div>
977
+ );
978
+ }
979
+
980
+ // Settings-only screen (step 7). Owns the single GET /api/schedule load; caches working state in a
981
+ // parent ref so edits survive the per-step remount (the wizard keys each step's motion.div by step).
982
+ function PulseCronSettings({ cacheRef }: { cacheRef: MutableRefObject<PulseCronCache | null> }) {
983
+ const hadCache = useRef(cacheRef.current != null).current;
984
+ const cached = cacheRef.current;
985
+ const [loading, setLoading] = useState(!hadCache);
986
+ const [loadError, setLoadError] = useState('');
987
+ const [pulse, setPulse] = useState<PulseConfig>(cached?.pulse ?? DEFAULT_PULSE);
988
+ const [originalPulse, setOriginalPulse] = useState<PulseConfig | null>(cached?.original ?? null);
989
+ const [crons, setCrons] = useState<CronView[]>(cached?.crons ?? []);
990
+
991
+ const fetchSchedule = async (withPulse: boolean) => {
992
+ const res = await authFetch('/api/schedule');
993
+ if (!res.ok) throw new Error('Failed to load schedule');
994
+ const d = await res.json();
995
+ setCrons(Array.isArray(d.crons) ? d.crons : []);
996
+ if (withPulse) {
997
+ const p: PulseConfig = {
998
+ enabled: !!d.pulse?.enabled,
999
+ intervalMinutes: Number(d.pulse?.intervalMinutes) || 60,
1000
+ quietHours: { start: d.pulse?.quietHours?.start ?? '22:00', end: d.pulse?.quietHours?.end ?? '07:00' },
1001
+ };
1002
+ setPulse(p);
1003
+ setOriginalPulse(p);
1004
+ }
1005
+ };
1006
+
1007
+ useEffect(() => {
1008
+ if (hadCache) return;
1009
+ let cancelled = false;
1010
+ fetchSchedule(true)
1011
+ .then(() => { if (!cancelled) setLoading(false); })
1012
+ .catch((err) => { if (!cancelled) { setLoadError(err.message || 'Failed to load'); setLoading(false); } });
1013
+ return () => { cancelled = true; };
1014
+ }, [hadCache]);
1015
+
1016
+ // Persist working state so navigating away and back doesn't lose pulse edits.
1017
+ useEffect(() => {
1018
+ if (loading) return;
1019
+ cacheRef.current = { pulse, original: originalPulse, crons };
1020
+ });
1021
+
1022
+ return (
1023
+ <div>
1024
+ <h1 className="text-xl font-bold text-white tracking-tight">Pulse &amp; Crons</h1>
1025
+ <p className="text-white/40 text-[13px] mt-1.5 leading-relaxed">
1026
+ Background wake-ups and scheduled tasks your agent runs on its own.
1027
+ </p>
1028
+
1029
+ {loading ? (
1030
+ <div className="flex items-center justify-center py-12 text-white/40"><LoaderCircle className="h-5 w-5 animate-spin" /></div>
1031
+ ) : loadError ? (
1032
+ <div className="mt-5 flex items-center gap-2 text-[13px] text-red-400"><TriangleAlert className="h-4 w-4 shrink-0" />{loadError}</div>
1033
+ ) : (
1034
+ <div className="mt-6 max-h-[50vh] overflow-y-auto pr-1 -mr-1">
1035
+ <PulseSection pulse={pulse} original={originalPulse} setPulse={setPulse} onPulseSaved={(saved) => setOriginalPulse(saved)} />
1036
+ <CronsSection crons={crons} onLocalUpdate={(fn) => setCrons(fn)} onChanged={() => { fetchSchedule(false).catch(() => {}); }} />
1037
+ </div>
1038
+ )}
1039
+ </div>
1040
+ );
1041
+ }
1042
+
524
1043
  /* ── Component ── */
525
1044
 
526
1045
  interface Props {
@@ -686,6 +1205,17 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
686
1205
  // Environment Variables screen working-state cache (settings mode). Survives the per-step
687
1206
  // remount so edits aren't lost when navigating between settings screens; dies with the wizard.
688
1207
  const envCacheRef = useRef<EnvCache | null>(null);
1208
+ // Same pattern for the Pulse & Crons screen (step 7).
1209
+ const pulseCacheRef = useRef<PulseCronCache | null>(null);
1210
+
1211
+ // Settings mode (hub-and-spoke): each screen saves itself. `settingsOrig` is the last-saved
1212
+ // snapshot used both to show a Save button only when a screen actually changed, and to build a
1213
+ // payload that touches only the active screen's fields (the worker's /api/onboard is a full
1214
+ // overwrite, so other screens' fields must be sent at their saved values).
1215
+ const [settingsOrig, setSettingsOrig] = useState({ userName: '', botName: '', provider: '', model: '', whisperEnabled: false, whisperKey: '' });
1216
+ const [settingsSaving, setSettingsSaving] = useState(false);
1217
+ const [settingsSaved, setSettingsSaved] = useState(false);
1218
+ const [settingsSaveError, setSettingsSaveError] = useState('');
689
1219
 
690
1220
  const isConnected = authState[provider] === 'connected';
691
1221
 
@@ -737,6 +1267,9 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
737
1267
  setExistingHandle({ username: data.handle.username, tier: data.handle.tier, url: data.handle.url });
738
1268
  setRegistered(true);
739
1269
  setRegisteredUrl(data.handle.url);
1270
+ } else if (data.agentName) {
1271
+ // Private-network mode has no handle — prefill the agent name so it isn't blank.
1272
+ setBotName(data.agentName);
740
1273
  }
741
1274
  if (data.portalUser) setPortalUser(data.portalUser);
742
1275
  if (data.portalConfigured) setPortalExists(true);
@@ -749,6 +1282,16 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
749
1282
  if (data.tunnelUrl) setTunnelUrl(data.tunnelUrl);
750
1283
  // If user has existing handle, default to 'relay'; otherwise default to 'tunnel'
751
1284
  if (!data.handle) setHandleChoice('tunnel');
1285
+ // Snapshot the saved values so settings-mode per-screen Save buttons can detect changes
1286
+ // and so a single-screen save never clobbers another screen's saved value.
1287
+ setSettingsOrig({
1288
+ userName: data.userName || '',
1289
+ botName: (data.handle && data.handle.username) || data.agentName || '',
1290
+ provider: data.provider || '',
1291
+ model: data.model || '',
1292
+ whisperEnabled: !!data.whisperEnabled,
1293
+ whisperKey: data.whisperKey || '',
1294
+ });
752
1295
  prefillDone.current = true;
753
1296
 
754
1297
  // Restore TOTP setup state if returning from authenticator app
@@ -1316,18 +1859,88 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1316
1859
  }
1317
1860
  case 4: return !!(provider && model && isConnected);
1318
1861
  case 5: return true;
1319
- case 6: return true; // Environment Variables (settings) — self-saving, last screen
1862
+ case 6: return true; // Environment Variables (settings) — self-saving
1863
+ case 7: return true; // Pulse & Crons (settings) — self-saving
1320
1864
  default: return false;
1321
1865
  }
1322
1866
  })();
1323
1867
 
1324
- const next = () => { if (canNext && step < TOTAL_STEPS - 1) setStep((s) => s + 1); };
1868
+ // Sequential advance is onboarding-only. Settings mode is hub-and-spoke: the user navigates via
1869
+ // the header dropdown / "Go to" menu and each screen saves itself, so there is no "Continue".
1870
+ const next = () => { if (isInitialSetup && canNext && step < TOTAL_STEPS - 1) setStep((s) => s + 1); };
1325
1871
  const back = () => { if (step > 0) setStep((s) => s - 1); };
1326
1872
 
1327
1873
  const handleKeyDown = (e: KeyboardEvent) => {
1328
1874
  if (e.key === 'Enter' && canNext) next();
1329
1875
  };
1330
1876
 
1877
+ // ── Settings mode: per-screen change detection + save ──
1878
+ // Only the currently-shown settings screen reports "dirty"; the footer Save appears only then.
1879
+ const settingsScreenDirty = (() => {
1880
+ if (isInitialSetup) return false;
1881
+ switch (step) {
1882
+ case 1: return userName.trim() !== settingsOrig.userName;
1883
+ case 2: return botName.trim() !== settingsOrig.botName;
1884
+ case 3: return step3Phase === 'password' && portalPass.length > 0 && portalValid && (portalExists ? portalOldPassVerified : true);
1885
+ case 4: return (provider !== settingsOrig.provider || model !== settingsOrig.model) && isConnected && !!model;
1886
+ case 5: return (whisperEnabled !== settingsOrig.whisperEnabled || (whisperEnabled && whisperKey !== settingsOrig.whisperKey))
1887
+ && !(whisperEnabled && (!whisperKey.startsWith('sk-') || whisperKey.length < 20));
1888
+ default: return false; // 0 = hub, 6 = env (self-saving)
1889
+ }
1890
+ })();
1891
+
1892
+ // Clear the transient saved/error banners whenever the user moves to another settings screen.
1893
+ useEffect(() => { setSettingsSaved(false); setSettingsSaveError(''); }, [step]);
1894
+
1895
+ const saveSettings = async () => {
1896
+ if (!settingsScreenDirty || settingsSaving) return;
1897
+ setSettingsSaving(true); setSettingsSaveError(''); setSettingsSaved(false);
1898
+ // Base the payload on the last-saved values, then override ONLY the active screen's fields —
1899
+ // so saving one screen never persists another screen's unsaved, in-progress edits.
1900
+ const payload: any = {
1901
+ userName: settingsOrig.userName,
1902
+ agentName: settingsOrig.botName || 'Bloby',
1903
+ provider: settingsOrig.provider,
1904
+ model: settingsOrig.model,
1905
+ apiKey: '',
1906
+ whisperEnabled: settingsOrig.whisperEnabled,
1907
+ whisperKey: settingsOrig.whisperEnabled ? settingsOrig.whisperKey : '',
1908
+ portalUser: portalUser.trim(),
1909
+ portalPass: '',
1910
+ };
1911
+ switch (step) {
1912
+ case 1: payload.userName = userName.trim(); break;
1913
+ case 2: payload.agentName = botName.trim() || 'Bloby'; break;
1914
+ case 3: payload.portalPass = portalPass; break;
1915
+ case 4: payload.provider = provider; payload.model = model; break;
1916
+ case 5: payload.whisperEnabled = whisperEnabled; payload.whisperKey = whisperEnabled ? whisperKey : ''; break;
1917
+ }
1918
+ try {
1919
+ if (onSave) await onSave(payload);
1920
+ else await fetch('/api/onboard', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
1921
+ // Advance the saved snapshot so the Save button hides (screen no longer dirty).
1922
+ setSettingsOrig((o) => {
1923
+ switch (step) {
1924
+ case 1: return { ...o, userName: userName.trim() };
1925
+ case 2: return { ...o, botName: botName.trim() };
1926
+ case 4: return { ...o, provider, model };
1927
+ case 5: return { ...o, whisperEnabled, whisperKey };
1928
+ default: return o;
1929
+ }
1930
+ });
1931
+ if (step === 3) {
1932
+ // Password persisted — reset the change-password UI so it's no longer dirty.
1933
+ setPortalPass(''); setPortalPassConfirm(''); setPortalOldPass(''); setPortalOldPassVerified(false); setPortalChangeMode(false);
1934
+ setPortalExists(true);
1935
+ }
1936
+ setSettingsSaved(true);
1937
+ setSettingsSaving(false);
1938
+ } catch (err: any) {
1939
+ setSettingsSaveError(err.message || 'Save failed');
1940
+ setSettingsSaving(false);
1941
+ }
1942
+ };
1943
+
1331
1944
  const handleComplete = async () => {
1332
1945
  setSaving(true);
1333
1946
  const payload = {
@@ -1477,13 +2090,6 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1477
2090
  onChange={(id) => setStep(Number(id))}
1478
2091
  />
1479
2092
  </div>
1480
- <button
1481
- onClick={next}
1482
- className="mt-4 px-7 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center gap-2"
1483
- >
1484
- Continue
1485
- <ArrowRight className="h-4 w-4" />
1486
- </button>
1487
2093
  </div>
1488
2094
  )}
1489
2095
 
@@ -1509,6 +2115,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1509
2115
  data-lpignore="true"
1510
2116
  className={inputCls + ' flex-1'}
1511
2117
  />
2118
+ {isInitialSetup && (
1512
2119
  <button
1513
2120
  onClick={next}
1514
2121
  disabled={!canNext}
@@ -1516,6 +2123,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1516
2123
  >
1517
2124
  <ArrowRight className="h-5 w-5" />
1518
2125
  </button>
2126
+ )}
1519
2127
  </div>
1520
2128
  </div>
1521
2129
  )}
@@ -1632,6 +2240,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1632
2240
  </div>
1633
2241
  )}
1634
2242
 
2243
+ {isInitialSetup && (
1635
2244
  <button
1636
2245
  onClick={next}
1637
2246
  disabled={!canNext}
@@ -1640,6 +2249,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1640
2249
  Continue
1641
2250
  <ArrowRight className="h-4 w-4" />
1642
2251
  </button>
2252
+ )}
1643
2253
  </div>
1644
2254
  )}
1645
2255
 
@@ -1710,6 +2320,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1710
2320
  <p className="text-emerald-400/60 text-[12px] mt-1 font-mono">{registeredUrl}</p>
1711
2321
  </div>
1712
2322
  <div className="flex gap-2 mt-4">
2323
+ {isInitialSetup && (
1713
2324
  <button
1714
2325
  onClick={next}
1715
2326
  className="flex-1 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2"
@@ -1717,6 +2328,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1717
2328
  Continue
1718
2329
  <ArrowRight className="h-4 w-4" />
1719
2330
  </button>
2331
+ )}
1720
2332
  <button
1721
2333
  onClick={() => {
1722
2334
  setShowChangeConfirm(true);
@@ -1971,8 +2583,8 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1971
2583
  </button>
1972
2584
  )}
1973
2585
 
1974
- {/* Continue after claim */}
1975
- {registered && (
2586
+ {/* Continue after claim (onboarding only — settings is hub-and-spoke) */}
2587
+ {isInitialSetup && registered && (
1976
2588
  <button
1977
2589
  onClick={next}
1978
2590
  className="w-full mt-4 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2"
@@ -2419,6 +3031,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
2419
3031
  )}
2420
3032
  </div>
2421
3033
 
3034
+ {isInitialSetup && (
2422
3035
  <button
2423
3036
  onClick={next}
2424
3037
  disabled={!canNext}
@@ -2427,6 +3040,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
2427
3040
  Continue
2428
3041
  <ArrowRight className="h-4 w-4" />
2429
3042
  </button>
3043
+ )}
2430
3044
  </motion.div>
2431
3045
  )}
2432
3046
 
@@ -3148,7 +3762,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
3148
3762
  </>
3149
3763
  )}
3150
3764
 
3151
- {isConnected && (
3765
+ {isInitialSetup && isConnected && (
3152
3766
  <button
3153
3767
  onClick={next}
3154
3768
  disabled={!canNext}
@@ -3242,17 +3856,19 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
3242
3856
  </div>
3243
3857
  )}
3244
3858
 
3245
- <button
3246
- onClick={handleComplete}
3247
- disabled={saving || (whisperEnabled && (!whisperKey.startsWith('sk-') || whisperKey.length < 20))}
3248
- className="w-full mt-5 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
3249
- >
3250
- {saving ? (
3251
- <><LoaderCircle className="h-4 w-4 animate-spin" />{isInitialSetup ? 'Setting up...' : 'Saving...'}</>
3252
- ) : (
3253
- <>{isInitialSetup ? 'Complete Setup' : 'Save Changes'}<ArrowRight className="h-4 w-4" /></>
3254
- )}
3255
- </button>
3859
+ {isInitialSetup && (
3860
+ <button
3861
+ onClick={handleComplete}
3862
+ disabled={saving || (whisperEnabled && (!whisperKey.startsWith('sk-') || whisperKey.length < 20))}
3863
+ className="w-full mt-5 py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40"
3864
+ >
3865
+ {saving ? (
3866
+ <><LoaderCircle className="h-4 w-4 animate-spin" />Setting up...</>
3867
+ ) : (
3868
+ <>Complete Setup<ArrowRight className="h-4 w-4" /></>
3869
+ )}
3870
+ </button>
3871
+ )}
3256
3872
 
3257
3873
  {!whisperEnabled && (
3258
3874
  <p className="text-center text-white/20 text-[11px] mt-2.5">
@@ -3373,6 +3989,9 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
3373
3989
 
3374
3990
  {/* ── Step 6: Environment Variables (settings re-run only) ── */}
3375
3991
  {step === 6 && !isInitialSetup && <EnvSettings cacheRef={envCacheRef} />}
3992
+
3993
+ {/* ── Step 7: Pulse & Crons (settings re-run only) ── */}
3994
+ {step === 7 && !isInitialSetup && <PulseCronSettings cacheRef={pulseCacheRef} />}
3376
3995
  </motion.div>
3377
3996
  </AnimatePresence>
3378
3997
 
@@ -3387,6 +4006,35 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
3387
4006
  </button>
3388
4007
  </div>
3389
4008
  )}
4009
+
4010
+ {/* Settings mode: per-screen Save — shown only when the current screen has changes.
4011
+ (Env screen, step 6, saves itself inside EnvSettings.) */}
4012
+ {!isInitialSetup && step >= 1 && step <= 5 && (settingsScreenDirty || settingsSaving || settingsSaved || settingsSaveError) && (
4013
+ <div className="px-8 pb-6 -mt-2">
4014
+ {settingsSaveError && (
4015
+ <p className="text-red-400 text-[12px] mb-2 flex items-center gap-1.5">
4016
+ <TriangleAlert className="h-3.5 w-3.5 shrink-0" />{settingsSaveError}
4017
+ </p>
4018
+ )}
4019
+ {settingsSaved && !settingsScreenDirty ? (
4020
+ <div className="flex items-center justify-center gap-2 py-3 text-emerald-400 text-[13px] font-medium">
4021
+ <Check className="h-4 w-4" /> Saved
4022
+ </div>
4023
+ ) : (
4024
+ <button
4025
+ onClick={saveSettings}
4026
+ disabled={settingsSaving || !settingsScreenDirty}
4027
+ className="w-full py-3 bg-gradient-brand hover:opacity-90 text-white text-[14px] font-semibold rounded-full transition-colors flex items-center justify-center gap-2 disabled:opacity-40 disabled:cursor-not-allowed"
4028
+ >
4029
+ {settingsSaving ? (
4030
+ <><LoaderCircle className="h-4 w-4 animate-spin" />Saving...</>
4031
+ ) : (
4032
+ <><Check className="h-4 w-4" />Save changes</>
4033
+ )}
4034
+ </button>
4035
+ )}
4036
+ </div>
4037
+ )}
3390
4038
  </motion.div>
3391
4039
  </div>
3392
4040
  );