bloby-bot 0.59.0 → 0.60.1
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.
- package/dist-bloby/assets/{bloby-8GjzRxjC.js → bloby-DO7g-v11.js} +4 -4
- package/dist-bloby/assets/globals-CF0bs396.css +2 -0
- package/dist-bloby/assets/{globals-D-b6XZqk.js → globals-CwR3dDCz.js} +2 -2
- package/dist-bloby/assets/{highlighted-body-OFNGDK62-DrKKm93B.js → highlighted-body-OFNGDK62-C2Wmb17B.js} +1 -1
- package/dist-bloby/assets/mermaid-GHXKKRXX-CILe07ZG.js +1 -0
- package/dist-bloby/assets/{onboard-DJNuzfZA.js → onboard-DcGLkITd.js} +1 -1
- package/dist-bloby/bloby.html +3 -3
- package/dist-bloby/onboard.html +3 -3
- package/package.json +1 -1
- package/supervisor/chat/OnboardWizard.tsx +146 -110
- package/supervisor/index.ts +21 -7
- package/worker/prompts/bloby-system-prompt-codex.txt +2 -2
- package/worker/prompts/bloby-system-prompt-pi.txt +2 -2
- package/worker/prompts/bloby-system-prompt.txt +2 -2
- package/dist-bloby/assets/globals-eJ7lScsq.css +0 -2
- package/dist-bloby/assets/mermaid-GHXKKRXX-CxqocSKs.js +0 -1
|
@@ -87,7 +87,7 @@ const HANDLES = [
|
|
|
87
87
|
|
|
88
88
|
/* ── Dropdown ── */
|
|
89
89
|
|
|
90
|
-
function ModelDropdown({ models, value, onChange, placeholder = 'Choose a model...' }: { models: { id: string; label: string }[]; value: string; onChange: (id: string) => void; placeholder?: string }) {
|
|
90
|
+
function ModelDropdown({ models, value, onChange, placeholder = 'Choose a model...', menuMaxHeight = 'max-h-[320px]' }: { models: { id: string; label: string }[]; value: string; onChange: (id: string) => void; placeholder?: string; menuMaxHeight?: string }) {
|
|
91
91
|
const [open, setOpen] = useState(false);
|
|
92
92
|
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);
|
|
93
93
|
const btnRef = useRef<HTMLButtonElement>(null);
|
|
@@ -135,7 +135,7 @@ function ModelDropdown({ models, value, onChange, placeholder = 'Choose a model.
|
|
|
135
135
|
<div
|
|
136
136
|
ref={menuRef}
|
|
137
137
|
style={{ position: 'fixed', top: pos.top, left: pos.left, width: pos.width, zIndex: 1000 }}
|
|
138
|
-
className=
|
|
138
|
+
className={`bg-[#222] border border-white/[0.08] rounded-xl shadow-xl py-1 ${menuMaxHeight} overflow-y-auto`}
|
|
139
139
|
>
|
|
140
140
|
{models.map((m) => (
|
|
141
141
|
<button
|
|
@@ -215,7 +215,7 @@ function GoToMenu({ onJump }: { onJump: (step: number) => void }) {
|
|
|
215
215
|
<div
|
|
216
216
|
ref={menuRef}
|
|
217
217
|
style={{ position: 'fixed', top: pos.top, right: pos.right, zIndex: 1000 }}
|
|
218
|
-
className="bg-[#222] border border-white/[0.08] rounded-xl shadow-xl py-1 min-w-[200px]"
|
|
218
|
+
className="bg-[#222] border border-white/[0.08] rounded-xl shadow-xl py-1 min-w-[200px] max-h-[188px] overflow-y-auto"
|
|
219
219
|
>
|
|
220
220
|
{SETTINGS_SCREENS.map((s) => (
|
|
221
221
|
<button
|
|
@@ -242,6 +242,7 @@ interface EnvCache {
|
|
|
242
242
|
values: Record<string, string>;
|
|
243
243
|
originals: Record<string, string>;
|
|
244
244
|
revealed: string[];
|
|
245
|
+
removed: string[];
|
|
245
246
|
newRows: { id: number; name: string; value: string }[];
|
|
246
247
|
newRowSeq: number;
|
|
247
248
|
saved: boolean;
|
|
@@ -266,6 +267,7 @@ function EnvSettings({ cacheRef }: { cacheRef: MutableRefObject<EnvCache | null>
|
|
|
266
267
|
const [values, setValues] = useState<Record<string, string>>(cached?.values ?? {});
|
|
267
268
|
const [originals, setOriginals] = useState<Record<string, string>>(cached?.originals ?? {});
|
|
268
269
|
const [revealed, setRevealed] = useState<Set<string>>(new Set(cached?.revealed ?? []));
|
|
270
|
+
const [removed, setRemoved] = useState<Set<string>>(new Set(cached?.removed ?? []));
|
|
269
271
|
const [newRows, setNewRows] = useState<{ id: number; name: string; value: string }[]>(cached?.newRows ?? []);
|
|
270
272
|
const newRowId = useRef(cached?.newRowSeq ?? 0);
|
|
271
273
|
const [saving, setSaving] = useState(false);
|
|
@@ -295,12 +297,14 @@ function EnvSettings({ cacheRef }: { cacheRef: MutableRefObject<EnvCache | null>
|
|
|
295
297
|
// Skip while loading so a navigate-away mid-fetch leaves the cache empty → refetch on return.
|
|
296
298
|
useEffect(() => {
|
|
297
299
|
if (loading) return;
|
|
298
|
-
cacheRef.current = { groups, values, originals, revealed: Array.from(revealed), newRows, newRowSeq: newRowId.current, saved };
|
|
300
|
+
cacheRef.current = { groups, values, originals, revealed: Array.from(revealed), removed: Array.from(removed), newRows, newRowSeq: newRowId.current, saved };
|
|
299
301
|
});
|
|
300
302
|
|
|
301
303
|
const validNewRows = newRows.filter((r) => r.name.trim() && r.value.trim() && ENV_NAME_RE.test(r.name.trim()));
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
+
const removedExisting = [...removed].filter((k) => k in originals);
|
|
305
|
+
// An edit to a var that's also marked for removal doesn't count — removal wins.
|
|
306
|
+
const changedExisting = Object.keys(values).filter((k) => values[k] !== originals[k] && !removed.has(k));
|
|
307
|
+
const dirty = changedExisting.length > 0 || validNewRows.length > 0 || removedExisting.length > 0;
|
|
304
308
|
|
|
305
309
|
const toggleReveal = (name: string) => {
|
|
306
310
|
setRevealed((prev) => {
|
|
@@ -310,29 +314,41 @@ function EnvSettings({ cacheRef }: { cacheRef: MutableRefObject<EnvCache | null>
|
|
|
310
314
|
});
|
|
311
315
|
};
|
|
312
316
|
|
|
317
|
+
const toggleRemoved = (name: string) => {
|
|
318
|
+
setSaved(false);
|
|
319
|
+
setRemoved((prev) => {
|
|
320
|
+
const next = new Set(prev);
|
|
321
|
+
if (next.has(name)) next.delete(name); else next.add(name);
|
|
322
|
+
return next;
|
|
323
|
+
});
|
|
324
|
+
};
|
|
325
|
+
|
|
313
326
|
const handleSave = async () => {
|
|
314
327
|
if (!dirty || saving) return;
|
|
315
328
|
setSaving(true); setSaveError(''); setSaved(false);
|
|
316
329
|
const vars: Record<string, string> = {};
|
|
317
330
|
for (const k of changedExisting) vars[k] = values[k];
|
|
318
331
|
for (const r of validNewRows) vars[r.name.trim()] = r.value.trim();
|
|
332
|
+
const removeList = removedExisting;
|
|
319
333
|
try {
|
|
320
334
|
const res = await authFetch('/api/env', {
|
|
321
335
|
method: 'POST',
|
|
322
336
|
headers: { 'Content-Type': 'application/json' },
|
|
323
|
-
body: JSON.stringify({ vars }),
|
|
337
|
+
body: JSON.stringify({ vars, remove: removeList }),
|
|
324
338
|
});
|
|
325
339
|
if (!res.ok) {
|
|
326
340
|
const d = await res.json().catch(() => ({ error: 'Save failed' }));
|
|
327
341
|
throw new Error(d.error || 'Save failed');
|
|
328
342
|
}
|
|
329
|
-
// Snapshot of values including the just-added rows, so `dirty`
|
|
343
|
+
// Snapshot of values including the just-added rows and minus the removed ones, so `dirty`
|
|
344
|
+
// resets after save.
|
|
330
345
|
const merged = { ...values };
|
|
331
346
|
for (const r of validNewRows) merged[r.name.trim()] = r.value.trim();
|
|
332
|
-
|
|
333
|
-
//
|
|
347
|
+
for (const k of removeList) delete merged[k];
|
|
348
|
+
// Drop removed vars from the displayed groups; surface newly-added vars under a "Custom" group
|
|
349
|
+
// (the writer appends them; on next load they'll re-group under whatever section precedes them).
|
|
334
350
|
setGroups((prev) => {
|
|
335
|
-
|
|
351
|
+
let next = prev.map((g) => ({ ...g, vars: g.vars.filter((v) => !removed.has(v.name)) }));
|
|
336
352
|
const existing = new Set(next.flatMap((g) => g.vars.map((v) => v.name)));
|
|
337
353
|
const toAdd = validNewRows.map((r) => r.name.trim()).filter((nm) => !existing.has(nm));
|
|
338
354
|
if (toAdd.length) {
|
|
@@ -340,11 +356,12 @@ function EnvSettings({ cacheRef }: { cacheRef: MutableRefObject<EnvCache | null>
|
|
|
340
356
|
if (!custom) { custom = { title: 'Custom', vars: [] }; next.push(custom); }
|
|
341
357
|
for (const nm of toAdd) custom.vars.push({ name: nm, value: merged[nm] });
|
|
342
358
|
}
|
|
343
|
-
return next;
|
|
359
|
+
return next.filter((g) => g.vars.length > 0); // hide groups emptied by removal
|
|
344
360
|
});
|
|
345
361
|
setValues(merged);
|
|
346
362
|
setOriginals(merged);
|
|
347
363
|
setNewRows([]);
|
|
364
|
+
setRemoved(new Set());
|
|
348
365
|
setSaved(true);
|
|
349
366
|
setSaving(false);
|
|
350
367
|
} catch (err: any) {
|
|
@@ -387,34 +404,60 @@ function EnvSettings({ cacheRef }: { cacheRef: MutableRefObject<EnvCache | null>
|
|
|
387
404
|
</div>
|
|
388
405
|
<div className="space-y-2.5">
|
|
389
406
|
{g.vars.map((v) => {
|
|
390
|
-
const
|
|
407
|
+
const isRemoved = removed.has(v.name);
|
|
408
|
+
const changed = !isRemoved && values[v.name] !== originals[v.name];
|
|
391
409
|
const show = revealed.has(v.name);
|
|
392
410
|
return (
|
|
393
411
|
<div key={v.name}>
|
|
394
412
|
<label className="flex items-center gap-1.5 text-[11px] text-white/40 mb-1">
|
|
395
|
-
<code className=
|
|
413
|
+
<code className={`font-mono ${isRemoved ? 'text-white/30 line-through' : 'text-white/50'}`}>{v.name}</code>
|
|
396
414
|
{changed && <span className="text-[#0069FE] text-[10px] font-medium">• edited</span>}
|
|
415
|
+
{isRemoved && <span className="text-red-400/80 text-[10px] font-medium">• will be removed</span>}
|
|
397
416
|
</label>
|
|
398
|
-
|
|
399
|
-
<
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
417
|
+
{isRemoved ? (
|
|
418
|
+
<div className="flex items-center gap-2 px-4 py-2.5 rounded-xl border border-red-500/20 bg-red-500/[0.04]">
|
|
419
|
+
<span className="flex-1 text-[13px] text-white/30 font-mono">••••••••</span>
|
|
420
|
+
<button
|
|
421
|
+
type="button"
|
|
422
|
+
onClick={() => toggleRemoved(v.name)}
|
|
423
|
+
className="text-[11px] text-white/50 hover:text-white/80 transition-colors"
|
|
424
|
+
>
|
|
425
|
+
Undo
|
|
426
|
+
</button>
|
|
427
|
+
</div>
|
|
428
|
+
) : (
|
|
429
|
+
<div className="flex items-center gap-2">
|
|
430
|
+
<div className="relative flex-1">
|
|
431
|
+
<input
|
|
432
|
+
type={show ? 'text' : 'password'}
|
|
433
|
+
value={values[v.name] ?? ''}
|
|
434
|
+
onChange={(e) => setValues((p) => ({ ...p, [v.name]: e.target.value }))}
|
|
435
|
+
autoComplete="off"
|
|
436
|
+
spellCheck={false}
|
|
437
|
+
data-1p-ignore
|
|
438
|
+
data-lpignore="true"
|
|
439
|
+
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"
|
|
440
|
+
/>
|
|
441
|
+
<button
|
|
442
|
+
type="button"
|
|
443
|
+
onClick={() => toggleReveal(v.name)}
|
|
444
|
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
|
|
445
|
+
aria-label={show ? 'Hide value' : 'Reveal value'}
|
|
446
|
+
>
|
|
447
|
+
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
448
|
+
</button>
|
|
449
|
+
</div>
|
|
450
|
+
<button
|
|
451
|
+
type="button"
|
|
452
|
+
onClick={() => toggleRemoved(v.name)}
|
|
453
|
+
className="shrink-0 p-2 text-white/25 hover:text-red-400 hover:bg-red-500/[0.06] rounded-lg transition-colors"
|
|
454
|
+
title="Remove variable"
|
|
455
|
+
aria-label="Remove variable"
|
|
456
|
+
>
|
|
457
|
+
<Trash2 className="h-4 w-4" />
|
|
458
|
+
</button>
|
|
459
|
+
</div>
|
|
460
|
+
)}
|
|
418
461
|
</div>
|
|
419
462
|
);
|
|
420
463
|
})}
|
|
@@ -489,7 +532,7 @@ function EnvSettings({ cacheRef }: { cacheRef: MutableRefObject<EnvCache | null>
|
|
|
489
532
|
<div className="mt-4 flex items-start gap-2.5 bg-amber-500/[0.06] border border-amber-500/20 rounded-xl px-4 py-3">
|
|
490
533
|
<TriangleAlert className="h-4 w-4 text-amber-400 mt-0.5 shrink-0" />
|
|
491
534
|
<p className="text-amber-300/80 text-[12px] leading-relaxed">
|
|
492
|
-
|
|
535
|
+
Changes detected. Your workspace backend will be restarted when you save.
|
|
493
536
|
</p>
|
|
494
537
|
</div>
|
|
495
538
|
)}
|
|
@@ -534,7 +577,6 @@ interface CronView {
|
|
|
534
577
|
id: string;
|
|
535
578
|
schedule: string;
|
|
536
579
|
task: string;
|
|
537
|
-
enabled: boolean;
|
|
538
580
|
oneShot?: boolean;
|
|
539
581
|
paused?: boolean;
|
|
540
582
|
hasTaskFile: boolean;
|
|
@@ -740,9 +782,7 @@ function PulseSection({ pulse, original, setPulse, onPulseSaved }: {
|
|
|
740
782
|
/>
|
|
741
783
|
</div>
|
|
742
784
|
</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>
|
|
785
|
+
<p className="text-white/35 text-[12px] mt-2 leading-relaxed">No pulses fire between these times.</p>
|
|
746
786
|
</div>
|
|
747
787
|
|
|
748
788
|
{saved && !dirty && (
|
|
@@ -777,8 +817,14 @@ function CronsSection({ crons, onChanged, onLocalUpdate }: {
|
|
|
777
817
|
const [confirmId, setConfirmId] = useState<string | null>(null);
|
|
778
818
|
const [downloading, setDownloading] = useState<Record<string, boolean>>({});
|
|
779
819
|
const [actionError, setActionError] = useState('');
|
|
820
|
+
const [expanded, setExpanded] = useState<Set<string>>(new Set()); // collapsed by default
|
|
780
821
|
|
|
781
822
|
const setRowBusy = (id: string, v: 'pause' | 'delete' | undefined) => setBusy((p) => ({ ...p, [id]: v }));
|
|
823
|
+
const toggleExpanded = (id: string) => setExpanded((prev) => {
|
|
824
|
+
const next = new Set(prev);
|
|
825
|
+
if (next.has(id)) next.delete(id); else next.add(id);
|
|
826
|
+
return next;
|
|
827
|
+
});
|
|
782
828
|
|
|
783
829
|
const handleTogglePause = async (c: CronView) => {
|
|
784
830
|
if (busy[c.id]) return;
|
|
@@ -869,105 +915,94 @@ function CronsSection({ crons, onChanged, onLocalUpdate }: {
|
|
|
869
915
|
) : (
|
|
870
916
|
crons.map((c) => {
|
|
871
917
|
const paused = !!c.paused;
|
|
872
|
-
const disabled = c.enabled === false;
|
|
873
|
-
const parked = paused || disabled;
|
|
874
918
|
const rowBusy = busy[c.id];
|
|
919
|
+
const isOpen = expanded.has(c.id);
|
|
875
920
|
const nextStr = formatNextRun(c.nextRun);
|
|
876
921
|
return (
|
|
877
|
-
<div key={c.id} className={`rounded-xl border
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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
|
-
)}
|
|
922
|
+
<div key={c.id} className={`rounded-xl border transition-colors ${paused ? 'border-white/[0.05] bg-white/[0.015]' : 'border-white/[0.06] bg-white/[0.02]'}`}>
|
|
923
|
+
{/* Header row — title + actions (always visible) */}
|
|
924
|
+
<div className="flex items-center gap-2 p-2.5">
|
|
925
|
+
<button
|
|
926
|
+
type="button"
|
|
927
|
+
onClick={() => toggleExpanded(c.id)}
|
|
928
|
+
className="flex items-center gap-2.5 flex-1 min-w-0 text-left"
|
|
929
|
+
>
|
|
930
|
+
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${paused ? 'bg-white/[0.04]' : c.oneShot ? 'bg-amber-500/10' : 'bg-[#0069FE]/10'}`}>
|
|
931
|
+
{c.oneShot
|
|
932
|
+
? <Zap className={`h-4 w-4 ${paused ? 'text-white/40' : 'text-amber-400'}`} />
|
|
933
|
+
: <Clock className={`h-4 w-4 ${paused ? 'text-white/40' : 'text-[#0069FE]'}`} />}
|
|
919
934
|
</div>
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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
|
+
<span className={`flex-1 min-w-0 truncate text-[13px] ${paused ? 'text-white/45' : 'text-white'}`}>{c.task || '(untitled task)'}</span>
|
|
936
|
+
{paused && (
|
|
937
|
+
<span className="shrink-0 text-[10px] font-medium text-white/40 bg-white/[0.05] rounded-full px-2 py-0.5">Paused</span>
|
|
938
|
+
)}
|
|
939
|
+
</button>
|
|
935
940
|
|
|
936
941
|
{confirmId === c.id ? (
|
|
937
|
-
<div className="flex items-center gap-1
|
|
938
|
-
<span className="text-[11px] text-white/
|
|
942
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
943
|
+
<span className="text-[11px] text-white/45 mr-0.5">Delete?</span>
|
|
939
944
|
<button
|
|
940
945
|
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
|
|
946
|
+
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 py-1 transition-colors disabled:opacity-50"
|
|
942
947
|
>
|
|
943
|
-
{rowBusy === 'delete' ? <LoaderCircle className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
|
|
944
|
-
Delete
|
|
948
|
+
{rowBusy === 'delete' ? <LoaderCircle className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}Delete
|
|
945
949
|
</button>
|
|
946
950
|
<button
|
|
947
951
|
type="button" onClick={() => setConfirmId(null)} disabled={rowBusy === 'delete'}
|
|
948
|
-
className="rounded-md text-white/40 hover:text-white/70 text-[11px] px-
|
|
952
|
+
className="rounded-md text-white/40 hover:text-white/70 text-[11px] px-1.5 py-1 transition-colors"
|
|
949
953
|
>
|
|
950
954
|
Cancel
|
|
951
955
|
</button>
|
|
952
956
|
</div>
|
|
953
957
|
) : (
|
|
954
|
-
<div className="flex items-center gap-
|
|
958
|
+
<div className="flex items-center gap-0.5 shrink-0">
|
|
955
959
|
<button
|
|
956
960
|
type="button" onClick={() => handleTogglePause(c)} disabled={!!rowBusy}
|
|
957
|
-
|
|
961
|
+
title={paused ? 'Resume' : 'Pause'} aria-label={paused ? 'Resume' : 'Pause'}
|
|
962
|
+
className="p-1.5 rounded-lg text-white/40 hover:text-white/80 hover:bg-white/[0.05] transition-colors disabled:opacity-50"
|
|
958
963
|
>
|
|
959
|
-
{rowBusy === 'pause' ? <LoaderCircle className="h-
|
|
960
|
-
{paused ? 'Resume' : 'Pause'}
|
|
964
|
+
{rowBusy === 'pause' ? <LoaderCircle className="h-4 w-4 animate-spin" /> : paused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
|
|
961
965
|
</button>
|
|
962
966
|
<button
|
|
963
967
|
type="button" onClick={() => setConfirmId(c.id)} disabled={!!rowBusy}
|
|
964
|
-
|
|
968
|
+
title="Delete" aria-label="Delete"
|
|
969
|
+
className="p-1.5 rounded-lg text-white/30 hover:text-red-400 hover:bg-red-500/[0.06] transition-colors disabled:opacity-50"
|
|
970
|
+
>
|
|
971
|
+
<Trash2 className="h-4 w-4" />
|
|
972
|
+
</button>
|
|
973
|
+
<button
|
|
974
|
+
type="button" onClick={() => toggleExpanded(c.id)}
|
|
975
|
+
title={isOpen ? 'Collapse' : 'Expand'} aria-label={isOpen ? 'Collapse' : 'Expand'}
|
|
976
|
+
className="p-1.5 rounded-lg text-white/30 hover:text-white/70 hover:bg-white/[0.05] transition-colors"
|
|
965
977
|
>
|
|
966
|
-
<
|
|
978
|
+
<ChevronDown className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
|
967
979
|
</button>
|
|
968
980
|
</div>
|
|
969
981
|
)}
|
|
970
982
|
</div>
|
|
983
|
+
|
|
984
|
+
{/* Expanded detail — schedule, next run, task file */}
|
|
985
|
+
{isOpen && (
|
|
986
|
+
<div className="px-2.5 pb-3 pl-[52px] -mt-0.5">
|
|
987
|
+
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[12px] text-white/45">
|
|
988
|
+
<span className="inline-flex items-center gap-1.5"><Clock className="h-3 w-3 text-white/30 shrink-0" />{c.description}</span>
|
|
989
|
+
{c.oneShot && <span className="text-amber-300/70">· One-time</span>}
|
|
990
|
+
{/* paused is already shown by the header pill — don't repeat it here */}
|
|
991
|
+
{!paused && nextStr && <span className="text-[#4AA8FF]">· Next: {nextStr}</span>}
|
|
992
|
+
</div>
|
|
993
|
+
{c.hasTaskFile && (
|
|
994
|
+
<button
|
|
995
|
+
type="button" onClick={() => handleDownloadTask(c)} disabled={downloading[c.id]}
|
|
996
|
+
className="group mt-2 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"
|
|
997
|
+
title="Download task instructions"
|
|
998
|
+
>
|
|
999
|
+
{downloading[c.id] ? <LoaderCircle className="h-3 w-3 animate-spin" /> : <FileText className="h-3 w-3 shrink-0" />}
|
|
1000
|
+
{c.id}.md
|
|
1001
|
+
<Download className="h-3 w-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
1002
|
+
</button>
|
|
1003
|
+
)}
|
|
1004
|
+
</div>
|
|
1005
|
+
)}
|
|
971
1006
|
</div>
|
|
972
1007
|
);
|
|
973
1008
|
})
|
|
@@ -2087,6 +2122,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
|
|
|
2087
2122
|
models={SETTINGS_SCREENS.map((s) => ({ id: String(s.step), label: s.label }))}
|
|
2088
2123
|
value=""
|
|
2089
2124
|
placeholder="Select a setting…"
|
|
2125
|
+
menuMaxHeight="max-h-[188px]"
|
|
2090
2126
|
onChange={(id) => setStep(Number(id))}
|
|
2091
2127
|
/>
|
|
2092
2128
|
</div>
|
package/supervisor/index.ts
CHANGED
|
@@ -1447,8 +1447,8 @@ mint();
|
|
|
1447
1447
|
req.on('end', () => {
|
|
1448
1448
|
if (tooLarge) return;
|
|
1449
1449
|
try {
|
|
1450
|
-
const { vars } = JSON.parse(body) as { vars
|
|
1451
|
-
if (!vars || typeof vars !== 'object') {
|
|
1450
|
+
const { vars, remove } = JSON.parse(body) as { vars?: Record<string, string>; remove?: string[] };
|
|
1451
|
+
if ((!vars || typeof vars !== 'object') && !Array.isArray(remove)) {
|
|
1452
1452
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1453
1453
|
res.end(JSON.stringify({ error: 'Missing vars object' }));
|
|
1454
1454
|
return;
|
|
@@ -1460,7 +1460,20 @@ mint();
|
|
|
1460
1460
|
lines = fs.readFileSync(envPath, 'utf-8').split('\n');
|
|
1461
1461
|
}
|
|
1462
1462
|
|
|
1463
|
-
|
|
1463
|
+
// Delete requested keys first (so removing then re-adding the same key in one request
|
|
1464
|
+
// behaves predictably). A key line is `KEY=...` / `KEY =...`.
|
|
1465
|
+
if (Array.isArray(remove)) {
|
|
1466
|
+
for (const rawKey of remove) {
|
|
1467
|
+
const key = String(rawKey).trim();
|
|
1468
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
|
1469
|
+
lines = lines.filter((l) => {
|
|
1470
|
+
const t = l.trim();
|
|
1471
|
+
return !(t.startsWith(`${key}=`) || t.startsWith(`${key} =`));
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
for (const [rawKey, rawValue] of Object.entries(vars || {})) {
|
|
1464
1477
|
const key = rawKey.trim();
|
|
1465
1478
|
// Validate the key as a real env var name; reject anything that could inject extra
|
|
1466
1479
|
// lines or break .env parsing.
|
|
@@ -1552,7 +1565,6 @@ mint();
|
|
|
1552
1565
|
id: c.id,
|
|
1553
1566
|
schedule: c.schedule,
|
|
1554
1567
|
task: typeof c.task === 'string' ? c.task : '',
|
|
1555
|
-
enabled: !!c.enabled, // mirror the scheduler's `!cron.enabled` skip (undefined → disabled)
|
|
1556
1568
|
oneShot: !!c.oneShot,
|
|
1557
1569
|
paused: c.paused === true,
|
|
1558
1570
|
hasTaskFile: CRON_ID_RE.test(c.id || '') && fs.existsSync(path.join(WORKSPACE_DIR, 'tasks', `${c.id}.md`)),
|
|
@@ -2166,7 +2178,9 @@ mint();
|
|
|
2166
2178
|
(!req.headers['sec-fetch-dest'] && String(req.headers['accept'] || '').includes('text/html'))
|
|
2167
2179
|
);
|
|
2168
2180
|
if (wantsHtml && isBackendDead()) {
|
|
2169
|
-
|
|
2181
|
+
// X-Bloby-Origin marks this as the agent's OWN branded page so the relay passes it through
|
|
2182
|
+
// (and never mistakes it for a Cloudflare tunnel error to be replaced).
|
|
2183
|
+
res.writeHead(503, { 'Content-Type': 'text/html', 'X-Bloby-Origin': 'supervisor', 'Cache-Control': 'no-store, no-cache, must-revalidate' });
|
|
2170
2184
|
res.end(backendDownPage(readBackendLogTail(100)));
|
|
2171
2185
|
return;
|
|
2172
2186
|
}
|
|
@@ -2174,7 +2188,7 @@ mint();
|
|
|
2174
2188
|
// Vite failed to boot (sentinel port) → serve the recovering page directly instead of
|
|
2175
2189
|
// proxying to a dead port. Chat (/bloby/*) is served earlier, so the lifeline stays up.
|
|
2176
2190
|
if (vitePorts.dashboard < 0) {
|
|
2177
|
-
res.writeHead(503, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store, no-cache, must-revalidate' });
|
|
2191
|
+
res.writeHead(503, { 'Content-Type': 'text/html', 'X-Bloby-Origin': 'supervisor', 'Cache-Control': 'no-store, no-cache, must-revalidate' });
|
|
2178
2192
|
res.end(RECOVERING_HTML);
|
|
2179
2193
|
return;
|
|
2180
2194
|
}
|
|
@@ -2216,7 +2230,7 @@ mint();
|
|
|
2216
2230
|
);
|
|
2217
2231
|
proxy.on('error', (e) => {
|
|
2218
2232
|
console.error(`[supervisor] Dashboard Vite proxy error: ${req.url}`, e.message);
|
|
2219
|
-
res.writeHead(503, { 'Content-Type': 'text/html' });
|
|
2233
|
+
res.writeHead(503, { 'Content-Type': 'text/html', 'X-Bloby-Origin': 'supervisor' });
|
|
2220
2234
|
res.end(RECOVERING_HTML);
|
|
2221
2235
|
});
|
|
2222
2236
|
req.pipe(proxy);
|
|
@@ -146,14 +146,14 @@ An array of scheduled tasks:
|
|
|
146
146
|
|
|
147
147
|
Your human can ask you to:
|
|
148
148
|
- Add a cron ("every morning at 9, summarize my notes")
|
|
149
|
-
- Remove
|
|
149
|
+
- Remove a cron ("stop the daily summary") — delete its entry from CRONS.json
|
|
150
150
|
- Change a schedule ("move the summary to 8am")
|
|
151
151
|
- List active crons ("what's scheduled?")
|
|
152
152
|
- Set a one-time reminder ("remind me at 3pm to call the dentist")
|
|
153
153
|
|
|
154
154
|
Just edit `CRONS.json` with the Write or Edit tool when asked. Each cron needs: `id` (unique slug), `schedule` (cron expression), `task` (what to do), `enabled` (boolean). Optionally add `"oneShot": true` for tasks that should run once and auto-delete — the scheduler removes them after they fire. Use `oneShot` for reminders, one-time alerts, and any task that doesn't repeat. The `paused` field is user-controlled — leave it alone (see below).
|
|
155
155
|
|
|
156
|
-
The `paused` field is **user-controlled** — it appears when your human pauses a cron from the Settings → Pulse & Crons screen. A paused cron has `"paused": true` and the scheduler skips it (it will not fire) while keeping the entry and its task file intact. Treat `paused` as read-only: **never set, change, or remove it yourself
|
|
156
|
+
The `paused` field is **user-controlled** — it appears when your human pauses a cron from the Settings → Pulse & Crons screen. A paused cron has `"paused": true` and the scheduler skips it (it will not fire) while keeping the entry and its task file intact. Treat `paused` as read-only: **never set, change, or remove it yourself** — preserve whatever `paused` value is already there when you edit a cron. If your human asks you in chat to pause or resume a cron, set `"paused": true` or `"paused": false`. To stop a cron permanently, delete its entry from CRONS.json (and its `tasks/{id}.md` file if it has one).
|
|
157
157
|
|
|
158
158
|
**Timezone: all cron schedules use system local time.** The scheduler evaluates crons against the system clock — no UTC conversion needed. When creating a cron, run `date` to check the current local time and write the schedule accordingly. If your human says "remind me at 3pm", use `0 15 * * *` — that's 3pm in whatever timezone the system is set to.
|
|
159
159
|
|
|
@@ -146,14 +146,14 @@ An array of scheduled tasks:
|
|
|
146
146
|
|
|
147
147
|
Your human can ask you to:
|
|
148
148
|
- Add a cron ("every morning at 9, summarize my notes")
|
|
149
|
-
- Remove
|
|
149
|
+
- Remove a cron ("stop the daily summary") — delete its entry from CRONS.json
|
|
150
150
|
- Change a schedule ("move the summary to 8am")
|
|
151
151
|
- List active crons ("what's scheduled?")
|
|
152
152
|
- Set a one-time reminder ("remind me at 3pm to call the dentist")
|
|
153
153
|
|
|
154
154
|
Just edit `CRONS.json` with the Write or Edit tool when asked. Each cron needs: `id` (unique slug), `schedule` (cron expression), `task` (what to do), `enabled` (boolean). Optionally add `"oneShot": true` for tasks that should run once and auto-delete — the scheduler removes them after they fire. Use `oneShot` for reminders, one-time alerts, and any task that doesn't repeat. The `paused` field is user-controlled — leave it alone (see below).
|
|
155
155
|
|
|
156
|
-
The `paused` field is **user-controlled** — it appears when your human pauses a cron from the Settings → Pulse & Crons screen. A paused cron has `"paused": true` and the scheduler skips it (it will not fire) while keeping the entry and its task file intact. Treat `paused` as read-only: **never set, change, or remove it yourself
|
|
156
|
+
The `paused` field is **user-controlled** — it appears when your human pauses a cron from the Settings → Pulse & Crons screen. A paused cron has `"paused": true` and the scheduler skips it (it will not fire) while keeping the entry and its task file intact. Treat `paused` as read-only: **never set, change, or remove it yourself** — preserve whatever `paused` value is already there when you edit a cron. If your human asks you in chat to pause or resume a cron, set `"paused": true` or `"paused": false`. To stop a cron permanently, delete its entry from CRONS.json (and its `tasks/{id}.md` file if it has one).
|
|
157
157
|
|
|
158
158
|
**Timezone: all cron schedules use system local time.** The scheduler evaluates crons against the system clock — no UTC conversion needed. When creating a cron, run `date` to check the current local time and write the schedule accordingly. If your human says "remind me at 3pm", use `0 15 * * *` — that's 3pm in whatever timezone the system is set to.
|
|
159
159
|
|
|
@@ -146,14 +146,14 @@ An array of scheduled tasks:
|
|
|
146
146
|
|
|
147
147
|
Your human can ask you to:
|
|
148
148
|
- Add a cron ("every morning at 9, summarize my notes")
|
|
149
|
-
- Remove
|
|
149
|
+
- Remove a cron ("stop the daily summary") — delete its entry from CRONS.json
|
|
150
150
|
- Change a schedule ("move the summary to 8am")
|
|
151
151
|
- List active crons ("what's scheduled?")
|
|
152
152
|
- Set a one-time reminder ("remind me at 3pm to call the dentist")
|
|
153
153
|
|
|
154
154
|
Just edit `CRONS.json` with the Write or Edit tool when asked. Each cron needs: `id` (unique slug), `schedule` (cron expression), `task` (what to do), `enabled` (boolean). Optionally add `"oneShot": true` for tasks that should run once and auto-delete — the scheduler removes them after they fire. Use `oneShot` for reminders, one-time alerts, and any task that doesn't repeat. The `paused` field is user-controlled — leave it alone (see below).
|
|
155
155
|
|
|
156
|
-
The `paused` field is **user-controlled** — it appears when your human pauses a cron from the Settings → Pulse & Crons screen. A paused cron has `"paused": true` and the scheduler skips it (it will not fire) while keeping the entry and its task file intact. Treat `paused` as read-only: **never set, change, or remove it yourself
|
|
156
|
+
The `paused` field is **user-controlled** — it appears when your human pauses a cron from the Settings → Pulse & Crons screen. A paused cron has `"paused": true` and the scheduler skips it (it will not fire) while keeping the entry and its task file intact. Treat `paused` as read-only: **never set, change, or remove it yourself** — preserve whatever `paused` value is already there when you edit a cron. If your human asks you in chat to pause or resume a cron, set `"paused": true` or `"paused": false`. To stop a cron permanently, delete its entry from CRONS.json (and its `tasks/{id}.md` file if it has one).
|
|
157
157
|
|
|
158
158
|
**Timezone: all cron schedules use system local time.** The scheduler evaluates crons against the system clock — no UTC conversion needed. When creating a cron, run `date` to check the current local time and write the schedule accordingly. If your human says "remind me at 3pm", use `0 15 * * *` — that's 3pm in whatever timezone the system is set to.
|
|
159
159
|
|