bloby-bot 0.56.0 → 0.57.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,7 +1,8 @@
1
- import { useState, useRef, useEffect, type KeyboardEvent } from 'react';
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 } 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 } from 'lucide-react';
4
4
  import { motion, AnimatePresence } from 'framer-motion';
5
+ import { authFetch } from './src/lib/auth';
5
6
 
6
7
  /* ── Access detection ── */
7
8
 
@@ -86,7 +87,7 @@ const HANDLES = [
86
87
 
87
88
  /* ── Dropdown ── */
88
89
 
89
- function ModelDropdown({ models, value, onChange }: { models: { id: string; label: string }[]; value: string; onChange: (id: string) => void }) {
90
+ function ModelDropdown({ models, value, onChange, placeholder = 'Choose a model...' }: { models: { id: string; label: string }[]; value: string; onChange: (id: string) => void; placeholder?: string }) {
90
91
  const [open, setOpen] = useState(false);
91
92
  const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);
92
93
  const btnRef = useRef<HTMLButtonElement>(null);
@@ -126,7 +127,7 @@ function ModelDropdown({ models, value, onChange }: { models: { id: string; labe
126
127
  className="w-full flex items-center justify-between gap-2 bg-white/[0.03] border border-white/[0.08] text-white rounded-xl px-4 py-2.5 text-[13px] outline-none hover:border-white/15 focus:border-[#0069FE]/30 transition-colors overflow-hidden"
127
128
  >
128
129
  <span className={`min-w-0 truncate text-left ${selected ? 'text-white' : 'text-white/20'}`}>
129
- {selected ? selected.label : 'Choose a model...'}
130
+ {selected ? selected.label : placeholder}
130
131
  </span>
131
132
  <ChevronDown className={`h-4 w-4 shrink-0 text-white/30 transition-transform ${open ? 'rotate-180' : ''}`} />
132
133
  </button>
@@ -156,6 +157,370 @@ function ModelDropdown({ models, value, onChange }: { models: { id: string; labe
156
157
  );
157
158
  }
158
159
 
160
+ /* ── Settings mode: screen registry + jump menu ── */
161
+
162
+ // The jumpable settings screens (settings re-run only). `step` maps to the wizard step index.
163
+ // Environment Variables (step 6) reuses the slot the onboarding-only "All Set" screen occupies.
164
+ const SETTINGS_SCREENS: { step: number; label: string }[] = [
165
+ { step: 1, label: 'Personal Info' },
166
+ { step: 2, label: 'Agent Name & Access' },
167
+ { step: 3, label: 'Security' },
168
+ { step: 4, label: 'AI Provider' },
169
+ { step: 5, label: 'Voice Messages' },
170
+ { step: 6, label: 'Environment Variables' },
171
+ ];
172
+
173
+ // Compact "Go to" dropdown shown in the settings header so the user can jump to any screen.
174
+ function GoToMenu({ onJump }: { onJump: (step: number) => void }) {
175
+ const [open, setOpen] = useState(false);
176
+ const [pos, setPos] = useState<{ top: number; right: number } | null>(null);
177
+ const btnRef = useRef<HTMLButtonElement>(null);
178
+ const menuRef = useRef<HTMLDivElement>(null);
179
+
180
+ useEffect(() => {
181
+ if (!open) return;
182
+ const recalc = () => {
183
+ const r = btnRef.current?.getBoundingClientRect();
184
+ if (r) setPos({ top: r.bottom + 6, right: window.innerWidth - r.right });
185
+ };
186
+ recalc();
187
+ const handler = (e: MouseEvent) => {
188
+ const t = e.target as Node;
189
+ if (btnRef.current?.contains(t) || menuRef.current?.contains(t)) return;
190
+ setOpen(false);
191
+ };
192
+ document.addEventListener('mousedown', handler);
193
+ window.addEventListener('resize', recalc);
194
+ window.addEventListener('scroll', recalc, true);
195
+ return () => {
196
+ document.removeEventListener('mousedown', handler);
197
+ window.removeEventListener('resize', recalc);
198
+ window.removeEventListener('scroll', recalc, true);
199
+ };
200
+ }, [open]);
201
+
202
+ return (
203
+ <>
204
+ <button
205
+ ref={btnRef}
206
+ type="button"
207
+ onClick={() => setOpen((o) => !o)}
208
+ className="flex items-center gap-1 text-white/50 hover:text-white/80 text-[12px] font-medium px-2.5 py-1 rounded-lg hover:bg-white/[0.05] transition-colors"
209
+ >
210
+ Go to
211
+ <ChevronDown className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`} />
212
+ </button>
213
+ {open && pos && createPortal(
214
+ <div
215
+ ref={menuRef}
216
+ style={{ position: 'fixed', top: pos.top, right: pos.right, zIndex: 1000 }}
217
+ className="bg-[#222] border border-white/[0.08] rounded-xl shadow-xl py-1 min-w-[200px]"
218
+ >
219
+ {SETTINGS_SCREENS.map((s) => (
220
+ <button
221
+ key={s.step}
222
+ onClick={() => { onJump(s.step); setOpen(false); }}
223
+ className="w-full text-left px-4 py-2 text-[13px] text-white/70 hover:bg-white/[0.04] hover:text-white transition-colors"
224
+ >
225
+ {s.label}
226
+ </button>
227
+ ))}
228
+ </div>,
229
+ document.body,
230
+ )}
231
+ </>
232
+ );
233
+ }
234
+
235
+ /* ── Settings mode: Environment Variables screen ── */
236
+
237
+ interface EnvVarRow { name: string; value: string }
238
+ interface EnvVarGroup { title: string; vars: EnvVarRow[] }
239
+ interface EnvCache {
240
+ groups: EnvVarGroup[];
241
+ values: Record<string, string>;
242
+ originals: Record<string, string>;
243
+ revealed: string[];
244
+ newRows: { id: number; name: string; value: string }[];
245
+ newRowSeq: number;
246
+ saved: boolean;
247
+ }
248
+
249
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
250
+
251
+ // A read/write view of the agent-controlled `workspace/.env`, grouped by `# Section` headers.
252
+ // Reads via GET /api/env, writes changed/added keys via POST /api/env (which restarts the backend).
253
+ // Self-saving and self-contained — not part of the wizard's batched onboard payload.
254
+ // `cacheRef` lets the working state survive this screen's unmount/remount when the user
255
+ // navigates to another settings screen and back (the wizard keys each step's motion.div by
256
+ // `step`, so it remounts). It lives in the parent and dies with the wizard, so each fresh
257
+ // open re-fetches from /api/env.
258
+ function EnvSettings({ cacheRef }: { cacheRef: MutableRefObject<EnvCache | null> }) {
259
+ // Whether we already have cached working state at mount — captured once per instance.
260
+ const hadCache = useRef(cacheRef.current != null).current;
261
+ const cached = cacheRef.current;
262
+ const [groups, setGroups] = useState<EnvVarGroup[]>(cached?.groups ?? []);
263
+ const [loading, setLoading] = useState(!hadCache);
264
+ const [loadError, setLoadError] = useState('');
265
+ const [values, setValues] = useState<Record<string, string>>(cached?.values ?? {});
266
+ const [originals, setOriginals] = useState<Record<string, string>>(cached?.originals ?? {});
267
+ const [revealed, setRevealed] = useState<Set<string>>(new Set(cached?.revealed ?? []));
268
+ const [newRows, setNewRows] = useState<{ id: number; name: string; value: string }[]>(cached?.newRows ?? []);
269
+ const newRowId = useRef(cached?.newRowSeq ?? 0);
270
+ const [saving, setSaving] = useState(false);
271
+ const [saved, setSaved] = useState(cached?.saved ?? false);
272
+ const [saveError, setSaveError] = useState('');
273
+
274
+ useEffect(() => {
275
+ if (hadCache) return; // hydrated from cache — don't clobber edits with a refetch
276
+ let cancelled = false;
277
+ authFetch('/api/env')
278
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error('Failed to load environment variables'))))
279
+ .then((data) => {
280
+ if (cancelled) return;
281
+ const gs: EnvVarGroup[] = Array.isArray(data.groups) ? data.groups : [];
282
+ const init: Record<string, string> = {};
283
+ for (const g of gs) for (const v of g.vars) init[v.name] = v.value;
284
+ setGroups(gs);
285
+ setValues(init);
286
+ setOriginals(init);
287
+ setLoading(false);
288
+ })
289
+ .catch((err) => { if (!cancelled) { setLoadError(err.message || 'Failed to load'); setLoading(false); } });
290
+ return () => { cancelled = true; };
291
+ }, [hadCache]);
292
+
293
+ // Persist working state to the parent ref so navigating away and back doesn't lose edits.
294
+ // Skip while loading so a navigate-away mid-fetch leaves the cache empty → refetch on return.
295
+ useEffect(() => {
296
+ if (loading) return;
297
+ cacheRef.current = { groups, values, originals, revealed: Array.from(revealed), newRows, newRowSeq: newRowId.current, saved };
298
+ });
299
+
300
+ const validNewRows = newRows.filter((r) => r.name.trim() && r.value.trim() && ENV_NAME_RE.test(r.name.trim()));
301
+ const changedExisting = Object.keys(values).filter((k) => values[k] !== originals[k]);
302
+ const dirty = changedExisting.length > 0 || validNewRows.length > 0;
303
+
304
+ const toggleReveal = (name: string) => {
305
+ setRevealed((prev) => {
306
+ const next = new Set(prev);
307
+ if (next.has(name)) next.delete(name); else next.add(name);
308
+ return next;
309
+ });
310
+ };
311
+
312
+ const handleSave = async () => {
313
+ if (!dirty || saving) return;
314
+ setSaving(true); setSaveError(''); setSaved(false);
315
+ const vars: Record<string, string> = {};
316
+ for (const k of changedExisting) vars[k] = values[k];
317
+ for (const r of validNewRows) vars[r.name.trim()] = r.value.trim();
318
+ try {
319
+ const res = await authFetch('/api/env', {
320
+ method: 'POST',
321
+ headers: { 'Content-Type': 'application/json' },
322
+ body: JSON.stringify({ vars }),
323
+ });
324
+ if (!res.ok) {
325
+ const d = await res.json().catch(() => ({ error: 'Save failed' }));
326
+ throw new Error(d.error || 'Save failed');
327
+ }
328
+ // Snapshot of values including the just-added rows, so `dirty` resets after save.
329
+ const merged = { ...values };
330
+ for (const r of validNewRows) merged[r.name.trim()] = r.value.trim();
331
+ // Surface newly-added vars under a "Custom" group in the UI (the writer appends them
332
+ // to the file; on next load they'll re-group under whatever section precedes them).
333
+ setGroups((prev) => {
334
+ const next = prev.map((g) => ({ ...g, vars: [...g.vars] }));
335
+ const existing = new Set(next.flatMap((g) => g.vars.map((v) => v.name)));
336
+ const toAdd = validNewRows.map((r) => r.name.trim()).filter((nm) => !existing.has(nm));
337
+ if (toAdd.length) {
338
+ let custom = next.find((g) => g.title === 'Custom');
339
+ if (!custom) { custom = { title: 'Custom', vars: [] }; next.push(custom); }
340
+ for (const nm of toAdd) custom.vars.push({ name: nm, value: merged[nm] });
341
+ }
342
+ return next;
343
+ });
344
+ setValues(merged);
345
+ setOriginals(merged);
346
+ setNewRows([]);
347
+ setSaved(true);
348
+ setSaving(false);
349
+ } catch (err: any) {
350
+ setSaveError(err.message || 'Save failed');
351
+ setSaving(false);
352
+ }
353
+ };
354
+
355
+ return (
356
+ <div>
357
+ <h1 className="text-xl font-bold text-white tracking-tight">Environment Variables</h1>
358
+ <p className="text-white/40 text-[13px] mt-1.5 leading-relaxed">
359
+ API keys and secrets stored in your workspace{' '}
360
+ <code className="text-white/55 font-mono text-[12px]">.env</code>. Your agent reads and writes these as you build.
361
+ </p>
362
+
363
+ {loading ? (
364
+ <div className="flex items-center justify-center py-12 text-white/40">
365
+ <LoaderCircle className="h-5 w-5 animate-spin" />
366
+ </div>
367
+ ) : loadError ? (
368
+ <div className="mt-5 flex items-center gap-2 text-[13px] text-red-400">
369
+ <TriangleAlert className="h-4 w-4 shrink-0" />{loadError}
370
+ </div>
371
+ ) : (
372
+ <div className="mt-5 space-y-5 max-h-[44vh] overflow-y-auto pr-1 -mr-1">
373
+ {groups.length === 0 && newRows.length === 0 && (
374
+ <div className="rounded-xl border border-white/[0.06] bg-white/[0.02] px-4 py-7 text-center">
375
+ <KeyRound className="h-5 w-5 text-white/25 mx-auto mb-2" />
376
+ <p className="text-white/40 text-[13px]">No environment variables yet.</p>
377
+ <p className="text-white/25 text-[12px] mt-1">Ask your agent to add an integration, or add one manually below.</p>
378
+ </div>
379
+ )}
380
+
381
+ {groups.map((g) => (
382
+ <div key={g.title || '__untitled'}>
383
+ <div className="flex items-center gap-2 mb-2">
384
+ <KeyRound className="h-3.5 w-3.5 text-[#0069FE]/70" />
385
+ <span className="text-[12px] font-semibold text-white/70 uppercase tracking-wide">{g.title || 'Other'}</span>
386
+ </div>
387
+ <div className="space-y-2.5">
388
+ {g.vars.map((v) => {
389
+ const changed = values[v.name] !== originals[v.name];
390
+ const show = revealed.has(v.name);
391
+ return (
392
+ <div key={v.name}>
393
+ <label className="flex items-center gap-1.5 text-[11px] text-white/40 mb-1">
394
+ <code className="font-mono text-white/50">{v.name}</code>
395
+ {changed && <span className="text-[#0069FE] text-[10px] font-medium">• edited</span>}
396
+ </label>
397
+ <div className="relative">
398
+ <input
399
+ type={show ? 'text' : 'password'}
400
+ value={values[v.name] ?? ''}
401
+ onChange={(e) => setValues((p) => ({ ...p, [v.name]: e.target.value }))}
402
+ autoComplete="off"
403
+ spellCheck={false}
404
+ data-1p-ignore
405
+ data-lpignore="true"
406
+ 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"
407
+ />
408
+ <button
409
+ type="button"
410
+ onClick={() => toggleReveal(v.name)}
411
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
412
+ aria-label={show ? 'Hide value' : 'Reveal value'}
413
+ >
414
+ {show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
415
+ </button>
416
+ </div>
417
+ </div>
418
+ );
419
+ })}
420
+ </div>
421
+ </div>
422
+ ))}
423
+
424
+ {newRows.length > 0 && (
425
+ <div>
426
+ <div className="flex items-center gap-2 mb-2">
427
+ <Plus className="h-3.5 w-3.5 text-emerald-400/70" />
428
+ <span className="text-[12px] font-semibold text-white/70 uppercase tracking-wide">New</span>
429
+ </div>
430
+ <div className="space-y-3">
431
+ {newRows.map((r) => {
432
+ const nameInvalid = r.name.trim().length > 0 && !ENV_NAME_RE.test(r.name.trim());
433
+ return (
434
+ <div key={r.id} className="flex items-start gap-2">
435
+ <div className="flex-1 space-y-1.5">
436
+ <input
437
+ type="text"
438
+ value={r.name}
439
+ onChange={(e) => setNewRows((p) => p.map((x) => (x.id === r.id ? { ...x, name: e.target.value } : x)))}
440
+ placeholder="VARIABLE_NAME"
441
+ autoComplete="off"
442
+ spellCheck={false}
443
+ data-1p-ignore
444
+ data-lpignore="true"
445
+ className={`w-full bg-white/[0.03] border ${nameInvalid ? 'border-red-500/40' : 'border-white/[0.08]'} text-white rounded-xl px-4 py-2.5 text-[13px] font-mono outline-none focus:border-[#0069FE]/30 transition-colors`}
446
+ />
447
+ <input
448
+ type="text"
449
+ value={r.value}
450
+ onChange={(e) => setNewRows((p) => p.map((x) => (x.id === r.id ? { ...x, value: e.target.value } : x)))}
451
+ placeholder="value"
452
+ autoComplete="off"
453
+ spellCheck={false}
454
+ data-1p-ignore
455
+ data-lpignore="true"
456
+ className="w-full bg-white/[0.03] border border-white/[0.08] text-white rounded-xl px-4 py-2.5 text-[13px] font-mono outline-none focus:border-[#0069FE]/30 transition-colors"
457
+ />
458
+ {nameInvalid && (
459
+ <p className="text-red-400/80 text-[11px]">Use letters, numbers and underscores; can't start with a number.</p>
460
+ )}
461
+ </div>
462
+ <button
463
+ type="button"
464
+ onClick={() => setNewRows((p) => p.filter((x) => x.id !== r.id))}
465
+ className="mt-2 text-white/25 hover:text-red-400/80 transition-colors"
466
+ aria-label="Remove"
467
+ >
468
+ <X className="h-4 w-4" />
469
+ </button>
470
+ </div>
471
+ );
472
+ })}
473
+ </div>
474
+ </div>
475
+ )}
476
+
477
+ <button
478
+ type="button"
479
+ onClick={() => setNewRows((p) => [...p, { id: newRowId.current++, name: '', value: '' }])}
480
+ className="flex items-center gap-1.5 text-[12px] text-white/50 hover:text-white/80 transition-colors"
481
+ >
482
+ <Plus className="h-3.5 w-3.5" /> Add new
483
+ </button>
484
+ </div>
485
+ )}
486
+
487
+ {dirty && !saving && (
488
+ <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">
489
+ <TriangleAlert className="h-4 w-4 text-amber-400 mt-0.5 shrink-0" />
490
+ <p className="text-amber-300/80 text-[12px] leading-relaxed">
491
+ API key change detected. Your workspace backend will be restarted when you save.
492
+ </p>
493
+ </div>
494
+ )}
495
+
496
+ {saved && !dirty && (
497
+ <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">
498
+ <Check className="h-4 w-4 text-emerald-400 shrink-0" />
499
+ <p className="text-emerald-300/90 text-[12px]">Saved — your workspace backend is restarting.</p>
500
+ </div>
501
+ )}
502
+
503
+ {saveError && (
504
+ <div className="mt-3 flex items-center gap-1.5 text-[12px] text-red-400">
505
+ <TriangleAlert className="h-3.5 w-3.5 shrink-0" />{saveError}
506
+ </div>
507
+ )}
508
+
509
+ <button
510
+ onClick={handleSave}
511
+ disabled={!dirty || saving}
512
+ 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 disabled:cursor-not-allowed"
513
+ >
514
+ {saving ? (
515
+ <><LoaderCircle className="h-4 w-4 animate-spin" />Saving...</>
516
+ ) : (
517
+ <><RefreshCw className="h-4 w-4" />Save &amp; restart backend</>
518
+ )}
519
+ </button>
520
+ </div>
521
+ );
522
+ }
523
+
159
524
  /* ── Component ── */
160
525
 
161
526
  interface Props {
@@ -166,7 +531,8 @@ interface Props {
166
531
  }
167
532
 
168
533
  export default function OnboardWizard({ onComplete, isInitialSetup = false, onSave, onTunnelSwitch }: Props) {
169
- const TOTAL_STEPS = isInitialSetup ? 7 : 6; // 0..5 normal, +step 6 "All Set" for initial
534
+ // 0..5 shared. Step 6 = "All Set" (onboarding) OR "Environment Variables" (settings re-run).
535
+ const TOTAL_STEPS = 7;
170
536
 
171
537
  const [step, setStep] = useState(0);
172
538
  const [userName, setUserName] = useState('');
@@ -317,6 +683,10 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
317
683
  // Pre-fill guard
318
684
  const prefillDone = useRef(false);
319
685
 
686
+ // Environment Variables screen working-state cache (settings mode). Survives the per-step
687
+ // remount so edits aren't lost when navigating between settings screens; dies with the wizard.
688
+ const envCacheRef = useRef<EnvCache | null>(null);
689
+
320
690
  const isConnected = authState[provider] === 'connected';
321
691
 
322
692
  // Persist/restore TOTP setup state across page reloads (mobile: OS suspends PWA on app-switch)
@@ -946,6 +1316,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
946
1316
  }
947
1317
  case 4: return !!(provider && model && isConnected);
948
1318
  case 5: return true;
1319
+ case 6: return true; // Environment Variables (settings) — self-saving, last screen
949
1320
  default: return false;
950
1321
  }
951
1322
  })();
@@ -1011,21 +1382,46 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1011
1382
  transition={{ duration: 0.3 }}
1012
1383
  className="relative w-full max-w-[480px] bg-[#181818] border border-white/[0.06] rounded-[24px] shadow-2xl overflow-hidden"
1013
1384
  >
1014
- {/* Step dots */}
1015
- <div className="flex justify-center gap-2 pt-6">
1016
- {Array.from({ length: TOTAL_STEPS }, (_, i) => (
1017
- <div
1018
- key={i}
1019
- className={`h-1.5 rounded-full transition-all duration-300 ${
1020
- i === step
1021
- ? 'w-7 bg-gradient-brand'
1022
- : i < step
1023
- ? 'w-1.5 bg-gradient-brand opacity-60'
1024
- : 'w-1.5 bg-white/10'
1025
- }`}
1026
- />
1027
- ))}
1028
- </div>
1385
+ {/* Onboarding: step dots. Settings: a header with hub-back, screen jump, and close. */}
1386
+ {isInitialSetup ? (
1387
+ <div className="flex justify-center gap-2 pt-6">
1388
+ {Array.from({ length: TOTAL_STEPS }, (_, i) => (
1389
+ <div
1390
+ key={i}
1391
+ className={`h-1.5 rounded-full transition-all duration-300 ${
1392
+ i === step
1393
+ ? 'w-7 bg-gradient-brand'
1394
+ : i < step
1395
+ ? 'w-1.5 bg-gradient-brand opacity-60'
1396
+ : 'w-1.5 bg-white/10'
1397
+ }`}
1398
+ />
1399
+ ))}
1400
+ </div>
1401
+ ) : (
1402
+ <div className="flex items-center justify-between px-5 pt-4 pb-1">
1403
+ {step === 0 ? (
1404
+ <span className="text-[13px] font-semibold text-white/80 pl-1">Settings</span>
1405
+ ) : (
1406
+ <button
1407
+ onClick={() => setStep(0)}
1408
+ className="flex items-center gap-1 text-white/50 hover:text-white/80 text-[13px] font-medium pl-1 pr-2 py-1 rounded-lg hover:bg-white/[0.05] transition-colors"
1409
+ >
1410
+ <ArrowLeft className="h-3.5 w-3.5" /> Settings
1411
+ </button>
1412
+ )}
1413
+ <div className="flex items-center gap-0.5">
1414
+ {step !== 0 && <GoToMenu onJump={(s) => setStep(s)} />}
1415
+ <button
1416
+ onClick={onComplete}
1417
+ className="text-white/40 hover:text-white/80 p-1.5 rounded-lg hover:bg-white/[0.05] transition-colors"
1418
+ aria-label="Close settings"
1419
+ >
1420
+ <X className="h-4 w-4" />
1421
+ </button>
1422
+ </div>
1423
+ </div>
1424
+ )}
1029
1425
 
1030
1426
  {/* Content */}
1031
1427
  <AnimatePresence mode="wait">
@@ -1037,8 +1433,8 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1037
1433
  transition={{ duration: 0.2, ease: 'easeOut' }}
1038
1434
  className="px-8 pt-6 pb-8"
1039
1435
  >
1040
- {/* ── Step 0: Welcome ── */}
1041
- {step === 0 && (
1436
+ {/* ── Step 0: Welcome (onboarding) ── */}
1437
+ {step === 0 && isInitialSetup && (
1042
1438
  <div className="flex flex-col items-center text-center">
1043
1439
  <video autoPlay loop muted playsInline className="h-[180px] mb-4">
1044
1440
  <source src="/bloby_say_hi.mov" type='video/mp4; codecs="hvc1"' />
@@ -1060,6 +1456,37 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1060
1456
  </div>
1061
1457
  )}
1062
1458
 
1459
+ {/* ── Step 0: Settings hub (settings re-run) ── */}
1460
+ {step === 0 && !isInitialSetup && (
1461
+ <div className="flex flex-col items-center text-center">
1462
+ <video autoPlay loop muted playsInline className="h-[150px] mb-4">
1463
+ <source src="/bloby_say_hi.mov" type='video/mp4; codecs="hvc1"' />
1464
+ <source src="/bloby_say_hi.webm" type="video/webm" />
1465
+ </video>
1466
+ <h1 className="text-2xl font-bold text-white tracking-tight">
1467
+ Settings
1468
+ </h1>
1469
+ <p className="text-white/40 text-[14px] mt-2 leading-relaxed max-w-[320px]">
1470
+ What would you like to change?
1471
+ </p>
1472
+ <div className="w-full mt-5">
1473
+ <ModelDropdown
1474
+ models={SETTINGS_SCREENS.map((s) => ({ id: String(s.step), label: s.label }))}
1475
+ value=""
1476
+ placeholder="Select a setting…"
1477
+ onChange={(id) => setStep(Number(id))}
1478
+ />
1479
+ </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
+ </div>
1488
+ )}
1489
+
1063
1490
  {/* ── Step 1: Your name ── */}
1064
1491
  {step === 1 && (
1065
1492
  <div>
@@ -1097,7 +1524,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1097
1524
  {step === 2 && tunnelMode === 'off' && (
1098
1525
  <div>
1099
1526
  <h1 className="text-xl font-bold text-white tracking-tight">
1100
- Bot Name & Access
1527
+ Agent Name & Access
1101
1528
  </h1>
1102
1529
  <p className="text-white/40 text-[13px] mt-1.5 leading-relaxed">
1103
1530
  Give your bot a name. This is used throughout the app as your bot's identity.
@@ -1219,7 +1646,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1219
1646
  {step === 2 && tunnelMode === 'named' && (
1220
1647
  <div>
1221
1648
  <h1 className="text-xl font-bold text-white tracking-tight">
1222
- Bot Name & Access
1649
+ Agent Name & Access
1223
1650
  </h1>
1224
1651
  <p className="text-white/40 text-[13px] mt-1.5 leading-relaxed">
1225
1652
  This is your bot's identity. Your named tunnel domain is already configured.
@@ -1266,7 +1693,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
1266
1693
  {step === 2 && tunnelMode === 'quick' && (
1267
1694
  <div>
1268
1695
  <h1 className="text-xl font-bold text-white tracking-tight">
1269
- Bot Name & Access
1696
+ Agent Name & Access
1270
1697
  </h1>
1271
1698
  <p className="text-white/40 text-[13px] mt-1.5 leading-relaxed">
1272
1699
  This is your bot's name and permanent handle — access it from anywhere.
@@ -2821,9 +3248,9 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
2821
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"
2822
3249
  >
2823
3250
  {saving ? (
2824
- <><LoaderCircle className="h-4 w-4 animate-spin" />Setting up...</>
3251
+ <><LoaderCircle className="h-4 w-4 animate-spin" />{isInitialSetup ? 'Setting up...' : 'Saving...'}</>
2825
3252
  ) : (
2826
- <>Complete Setup<ArrowRight className="h-4 w-4" /></>
3253
+ <>{isInitialSetup ? 'Complete Setup' : 'Save Changes'}<ArrowRight className="h-4 w-4" /></>
2827
3254
  )}
2828
3255
  </button>
2829
3256
 
@@ -2943,11 +3370,14 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
2943
3370
  </div>
2944
3371
  );
2945
3372
  })()}
3373
+
3374
+ {/* ── Step 6: Environment Variables (settings re-run only) ── */}
3375
+ {step === 6 && !isInitialSetup && <EnvSettings cacheRef={envCacheRef} />}
2946
3376
  </motion.div>
2947
3377
  </AnimatePresence>
2948
3378
 
2949
- {/* Back button (hidden on All Set step) */}
2950
- {step > 0 && step < TOTAL_STEPS - 1 && !(step === 6 && isInitialSetup) && (
3379
+ {/* Back link onboarding only; settings mode navigates via the header. */}
3380
+ {isInitialSetup && step > 0 && step < TOTAL_STEPS - 1 && (
2951
3381
  <div className="px-8 pb-5 -mt-3">
2952
3382
  <button
2953
3383
  onClick={back}