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.
@@ -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="bg-[#222] border border-white/[0.08] rounded-xl shadow-xl py-1 max-h-[320px] overflow-y-auto"
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 changedExisting = Object.keys(values).filter((k) => values[k] !== originals[k]);
303
- const dirty = changedExisting.length > 0 || validNewRows.length > 0;
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` resets after save.
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
- // Surface newly-added vars under a "Custom" group in the UI (the writer appends them
333
- // to the file; on next load they'll re-group under whatever section precedes them).
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
- const next = prev.map((g) => ({ ...g, vars: [...g.vars] }));
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 changed = values[v.name] !== originals[v.name];
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="font-mono text-white/50">{v.name}</code>
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
- <div className="relative">
399
- <input
400
- type={show ? 'text' : 'password'}
401
- value={values[v.name] ?? ''}
402
- onChange={(e) => setValues((p) => ({ ...p, [v.name]: e.target.value }))}
403
- autoComplete="off"
404
- spellCheck={false}
405
- data-1p-ignore
406
- data-lpignore="true"
407
- 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"
408
- />
409
- <button
410
- type="button"
411
- onClick={() => toggleReveal(v.name)}
412
- className="absolute right-3 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
413
- aria-label={show ? 'Hide value' : 'Reveal value'}
414
- >
415
- {show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
416
- </button>
417
- </div>
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
- API key change detected. Your workspace backend will be restarted when you save.
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 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
- )}
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
- </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
+ <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.5">
938
- <span className="text-[11px] text-white/50 mr-0.5">Delete?</span>
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.5 py-1 transition-colors disabled:opacity-50"
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-2 py-1 transition-colors"
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-1">
958
+ <div className="flex items-center gap-0.5 shrink-0">
955
959
  <button
956
960
  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"
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-3 w-3 animate-spin" /> : paused ? <Play className="h-3 w-3" /> : <Pause className="h-3 w-3" />}
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
- 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"
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
- <Trash2 className="h-3 w-3" />Delete
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>
@@ -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: Record<string, string> };
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
- for (const [rawKey, rawValue] of Object.entries(vars)) {
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
- res.writeHead(503, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store, no-cache, must-revalidate' });
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 or disable a cron ("stop the daily summary")
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.** When you edit a cron with the Write or Edit tool (changing its schedule, task, or `enabled`), preserve whatever `paused` value is already there. If your human asks you to "resume" or "unpause" a cron in chat, set `"paused": false` (or delete the field). To disable a cron yourself, use `enabled: false` leave `paused` for the user. `enabled` is the agent's switch; `paused` is the human's switch; the scheduler skips a cron when EITHER `enabled` is false OR `paused` is true.
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 or disable a cron ("stop the daily summary")
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.** When you edit a cron with the Write or Edit tool (changing its schedule, task, or `enabled`), preserve whatever `paused` value is already there. If your human asks you to "resume" or "unpause" a cron in chat, set `"paused": false` (or delete the field). To disable a cron yourself, use `enabled: false` leave `paused` for the user. `enabled` is the agent's switch; `paused` is the human's switch; the scheduler skips a cron when EITHER `enabled` is false OR `paused` is true.
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 or disable a cron ("stop the daily summary")
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.** When you edit a cron with the Write or Edit tool (changing its schedule, task, or `enabled`), preserve whatever `paused` value is already there. If your human asks you to "resume" or "unpause" a cron in chat, set `"paused": false` (or delete the field). To disable a cron yourself, use `enabled: false` leave `paused` for the user. `enabled` is the agent's switch; `paused` is the human's switch; the scheduler skips a cron when EITHER `enabled` is false OR `paused` is true.
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