File size: 21,289 Bytes
e9256c1
5b5da0e
ad23973
5b5da0e
 
ea9d6ed
545eab2
5b5da0e
 
 
 
 
 
 
 
ece21ed
5b5da0e
9c5792a
 
 
 
 
 
b4f26e5
9c5792a
 
 
 
 
 
 
 
b4f26e5
 
 
9c5792a
 
 
b4f26e5
 
 
 
 
 
9c5792a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b4f26e5
 
 
9c5792a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5b5da0e
40f8c9b
5b5da0e
 
 
 
 
 
 
 
94ef8c1
40f8c9b
 
5b5da0e
ad23973
e9256c1
 
095d172
e9256c1
 
095d172
e9256c1
095d172
df0df18
 
 
 
 
 
37775da
 
 
 
 
 
 
 
 
 
b60e122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ad23973
 
 
 
 
 
 
 
 
5b5da0e
ef84d06
5b5da0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a699cda
5b5da0e
 
94ef8c1
 
 
 
 
 
 
40f8c9b
 
 
 
 
 
 
 
 
 
 
 
5b5da0e
 
545eab2
5b5da0e
 
 
545eab2
5b5da0e
 
 
a9cda9b
5b5da0e
 
545eab2
 
 
 
 
87c8b12
 
 
 
 
 
5b5da0e
 
 
 
 
37775da
 
df0df18
 
 
37775da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
df0df18
 
b60e122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
585ab8a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b60e122
 
 
9c5792a
 
ad23973
 
 
ba0ce47
 
 
545eab2
 
 
 
 
 
 
ad23973
ba0ce47
a699cda
ba0ce47
ad23973
 
 
 
 
 
a699cda
ad23973
 
 
 
5b5da0e
ece21ed
 
 
 
 
 
 
5b5da0e
 
 
 
 
 
a14bdef
5b5da0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
import { useEffect, useState } from 'react';
import type { Cli } from '../types';
import * as api from '../api';
import SkillsEditor from './SkillsEditor';
import UsagePanel from './UsagePanel';
import { SunGlyph, MoonGlyph, RefreshGlyph, InfoGlyph } from './icons';
import Logo from './Logo';

type Page = 'general' | 'usage' | 'skills';
const PAGES: { id: Page; label: string }[] = [
  { id: 'general', label: 'General' },
  { id: 'usage', label: 'Usage' },
  { id: 'skills', label: 'Skills' },
];

interface Info { dataDir?: string; home?: string; spaceId?: string | null; spaceHost?: string | null; tmux?: boolean; canRelaunch?: boolean; secrets?: string[]; bucketUnverified?: boolean; }

function urlBase64ToUint8Array(base64: string) {
  const padding = '='.repeat((4 - (base64.length % 4)) % 4);
  const raw = atob((base64 + padding).replace(/-/g, '+').replace(/_/g, '/'));
  return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
}

type PushState = 'checking' | 'unsupported' | 'framed' | 'denied' | 'off' | 'on' | 'busy';

// "Enable notifications" for THIS device: agents can then message the operator
// (only when explicitly asked to — see the environment skill).
function PushRow() {
  const [state, setState] = useState<PushState>('checking');
  const [note, setNote] = useState('');
  useEffect(() => {
    (async () => {
      // Inside the huggingface.co iframe the browser blocks notification
      // permission + push — only the Space's direct URL works.
      if (window.top !== window.self) return setState('framed');
      if (!('serviceWorker' in navigator) || !('PushManager' in window)) return setState('unsupported');
      if (Notification.permission === 'denied') return setState('denied');
      try {
        // `ready` can hang in odd contexts — don't leave the row stuck forever.
        const reg = await Promise.race([
          navigator.serviceWorker.ready,
          new Promise<null>((r) => setTimeout(() => r(null), 4000)),
        ]);
        if (!reg) return setState('unsupported');
        setState((await reg.pushManager.getSubscription()) ? 'on' : 'off');
      } catch { setState('unsupported'); }
    })();
  }, []);

  const enable = async () => {
    setState('busy');
    setNote('');
    try {
      const reg = await navigator.serviceWorker.ready;
      const perm = await Notification.requestPermission();
      if (perm !== 'granted') { setState(perm === 'denied' ? 'denied' : 'off'); return; }
      const { publicKey } = await api.getPushKey();
      const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) });
      await api.subscribePush(sub);
      setState('on');
      setNote('This device will receive agent notifications.');
    } catch {
      setState('off');
      setNote('Could not subscribe — on iPhone, add the app to your Home Screen first.');
    }
    setTimeout(() => setNote(''), 6000);
  };

  const disable = async () => {
    setState('busy');
    try {
      const reg = await navigator.serviceWorker.ready;
      const sub = await reg.pushManager.getSubscription();
      if (sub) { await api.unsubscribePush(sub.endpoint); await sub.unsubscribe(); }
    } catch { /* ignore */ }
    setState('off');
  };

  const test = async () => {
    setNote('');
    try {
      const r = await api.sendTestNotification();
      setNote(r.sent > 0 ? 'Sent — check your notifications.' : 'No delivery — is this device subscribed?');
    } catch { setNote('Failed to send.'); }
    setTimeout(() => setNote(''), 6000);
  };

  return (
    <>
      <div className="setting-row" style={{ marginTop: 12 }}>
        <div>
          <div className="s-label">Notifications</div>
          <div className="s-help">
            Agents can message this device when you explicitly ask them to ("notify me when the tests pass").
            On iPhone: add the app to your Home Screen first, then enable here.
          </div>
          {note && <div className="s-help" style={{ marginTop: 6 }}>{note}</div>}
        </div>
        {state === 'framed' ? (
          <a className="btn-ghost" href={window.location.href} target="_blank" rel="noreferrer">Open directly to enable ↗</a>
        ) : state === 'unsupported' ? (
          <span className="s-muted">not supported here</span>
        ) : state === 'denied' ? (
          <span className="s-muted">blocked in browser settings</span>
        ) : state === 'on' ? (
          <span className="confirm-del">
            <button className="btn-ghost" onClick={test}>Send test</button>
            <button className="btn-ghost" onClick={disable}>Disable</button>
          </span>
        ) : (
          <button className="btn-ghost" disabled={state === 'busy' || state === 'checking'} onClick={enable}>
            {state === 'busy' ? '…' : 'Enable on this device'}
          </button>
        )}
      </div>
    </>
  );
}

export default function SettingsView({
  page, onPage, onClose, theme, onToggleTheme, clis, info, onShowWelcome, demoMode, onToggleDemo,
}: {
  page: Page;
  onPage: (p: Page) => void;
  onClose: () => void;
  theme: 'light' | 'dark';
  onToggleTheme: () => void;
  clis: Cli[];
  info: Info | null;
  onShowWelcome?: () => void;
  demoMode?: boolean;
  onToggleDemo?: () => void;
}) {
  const [relaunch, setRelaunch] = useState<{ busy?: boolean; msg?: string; confirm?: boolean }>({});
  const [secretKeys, setSecretKeys] = useState<string[]>([]);
  const [notes, setNotes] = useState<Record<string, string>>({});
  const [savedNotes, setSavedNotes] = useState<Record<string, string>>({});
  const [secretsSaved, setSecretsSaved] = useState<'idle' | 'saving' | 'saved'>('idle');
  useEffect(() => {
    api.getSecrets().then((d) => { setSecretKeys(d.detected); setNotes(d.notes || {}); setSavedNotes(d.notes || {}); }).catch(() => {});
  }, []);
  const notesDirty = secretKeys.some((k) => (notes[k] || '') !== (savedNotes[k] || ''));
  // One-line textareas that grow with their content.
  const grow = (el: HTMLTextAreaElement) => { el.style.height = 'auto'; el.style.height = `${el.scrollHeight}px`; };
  const growRef = (el: HTMLTextAreaElement | null) => { if (el) grow(el); };
  useEffect(() => {
    document.querySelectorAll<HTMLTextAreaElement>('textarea.secret-desc').forEach(grow);
  }, [secretKeys]);
  // Autosave: settle for a moment after the last keystroke, then persist.
  useEffect(() => {
    if (!notesDirty) return;
    const t = setTimeout(async () => {
      setSecretsSaved('saving');
      try { await api.saveSecrets(notes); setSavedNotes({ ...notes }); setSecretsSaved('saved'); setTimeout(() => setSecretsSaved('idle'), 1800); }
      catch { setSecretsSaved('idle'); }
    }, 900);
    return () => clearTimeout(t);
  }, [notes, notesDirty]);
  // Operator config (artifacts hub, jobs policy): load once, autosave on edit.
  const [cfg, setCfg] = useState<api.AmConfig | null>(null);
  const [savedCfg, setSavedCfg] = useState('');
  const [cfgSaved, setCfgSaved] = useState<'idle' | 'saving' | 'saved'>('idle');
  useEffect(() => {
    api.getConfig().then((c) => { setCfg(c); setSavedCfg(JSON.stringify(c)); }).catch(() => {});
  }, []);
  useEffect(() => {
    if (!cfg || JSON.stringify(cfg) === savedCfg) return;
    const t = setTimeout(async () => {
      setCfgSaved('saving');
      try {
        await api.saveConfig(cfg);
        setSavedCfg(JSON.stringify(cfg));
        setCfgSaved('saved');
        setTimeout(() => setCfgSaved('idle'), 1800);
      } catch { setCfgSaved('idle'); }
    }, 900);
    return () => clearTimeout(t);
  }, [cfg, savedCfg]);

  const doRelaunch = async () => {
    setRelaunch({ busy: true });
    try {
      const r = await api.relaunchSpace();
      if (r.ok) setRelaunch({ msg: 'Rebuilding — the Space will restart in ~1–2 min. Reload once it\'s back.' });
      else if (r.reason === 'no-token') setRelaunch({ msg: 'Add an HF_TOKEN secret (write access) to the Space to enable one-click relaunch, or use ⋮ → Factory reboot.' });
      else setRelaunch({ msg: `Couldn't relaunch (${r.reason}).` });
    } catch { setRelaunch({ msg: 'Request failed.' }); }
  };
  return (
    <div className="app settings">
      <aside className="sidebar">
        <div className="brand">
          <button className="icon-btn" onClick={onClose} title="Back"></button>
          <h1 style={{ flex: 1, marginLeft: 4 }}>Settings</h1>
        </div>
        <div className="settings-nav">
          {PAGES.map((p) => (
            <button key={p.id} className={`settings-navitem${page === p.id ? ' active' : ''}`} onClick={() => onPage(p.id)}>
              {p.label}
            </button>
          ))}
        </div>
      </aside>

      <div className="main settings-main">
        {page === 'general' && (
          <div className="settings-page">
            <h2>General</h2>

            <div className="setting-row">
              <div><div className="s-label">Theme</div><div className="s-help">Defaults to your system setting.</div></div>
              <button className="btn-ghost" onClick={onToggleTheme}>{theme === 'dark' ? <><MoonGlyph /> Dark</> : <><SunGlyph /> Light</>}</button>
            </div>

            {onShowWelcome && (
              <div className="setting-row">
                <div><div className="s-label">Welcome guide</div><div className="s-help">A quick tour of how the tool works.</div></div>
                <button className="btn-ghost" onClick={onShowWelcome}>Show guide</button>
              </div>
            )}

            {onToggleDemo && (
              <div className="setting-row">
                <div>
                  <div className="s-label">Demo mode{demoMode && <span className="save-flag">on</span>}</div>
                  <div className="s-help">Hides your current sessions from the sidebar so the Space looks fresh. Nothing is deleted, logins and secrets stay valid, and anything you start while it's on stays visible. Turn it off to bring everything back.</div>
                </div>
                <button className={demoMode ? 'btn-primary' : 'btn-ghost'} onClick={onToggleDemo}>
                  {demoMode ? 'Deactivate demo mode' : 'Start demo mode'}
                </button>
              </div>
            )}

            <h3>Agents</h3>
            <div className="s-help">A coloured dot means the agent is configured and ready. Log in once inside a session (or set a key as a Space secret) — credentials persist on the bucket across restarts and all sessions.</div>
            <div className="agent-rows">
              {clis.filter((c) => c.id !== 'shell' && c.id !== 'files').map((c) => {
                const ready = c.available && c.ready;
                return (
                  <div key={c.id} className="agent-row">
                    <span
                      className="status"
                      style={ready
                        ? { background: c.color }
                        : { background: 'var(--muted)', opacity: 0.4 }}
                    />
                    <Logo cli={c.id} size={14} tint={c.color} />
                    <span className="ar-name">{c.label}</span>
                    <span className="spacer" />
                    {c.available && c.version && <span className="ar-ver mono">v{c.version}</span>}
                    <span className={`ar-state${ready ? ' ok' : ''}`}>{!c.available ? 'unavailable' : ready ? 'ready' : 'needs setup'}</span>
                    {/* fixed slot so version/state columns align across rows */}
                    <span className="ar-info">
                      {c.available && !ready && c.setup && (
                        <span className="tip" tabIndex={0} data-tip={c.setup}><InfoGlyph className="tip-i" /></span>
                      )}
                    </span>
                  </div>
                );
              })}
            </div>

            <h3>Secrets &amp; variables{secretsSaved !== 'idle' && <span className="save-flag">{secretsSaved === 'saving' ? 'saving…' : 'saved ✓'}</span>}</h3>
            <div className="s-help">Detected by diffing the runtime environment against a build-time snapshot — names only, never values. Describe what each is for (saved automatically); this publishes an <span className="mono">environment</span> skill so every agent knows what's available.</div>
            {secretKeys.length === 0 ? (
              <div className="s-muted" style={{ marginTop: 8 }}>None detected.</div>
            ) : (
              <div className="secret-list">
                {secretKeys.map((k) => (
                  <div key={k} className="secret-row">
                    <div className="secret-name mono">{k}</div>
                    <textarea
                      className="secret-desc"
                      placeholder="What is this used for?"
                      rows={1}
                      value={notes[k] || ''}
                      ref={growRef}
                      onChange={(e) => { setNotes((n) => ({ ...n, [k]: e.target.value })); grow(e.currentTarget); }}
                    />
                  </div>
                ))}
              </div>
            )}

            <h3>Agent output &amp; compute{cfgSaved !== 'idle' && <span className="save-flag">{cfgSaved === 'saving' ? 'saving…' : 'saved ✓'}</span>}</h3>
            <div className="s-help">Both policies are published to agents through the <span className="mono">environment</span> skill.</div>
            {cfg && (
              <>
                <div className="setting-row">
                  <div>
                    <div className="s-label">Web artifacts</div>
                    <div className="s-help">Agents render rich results (reports, dashboards, visualizations) as HTML pages and publish them to a central artifacts Space with an index and direct links per page.</div>
                  </div>
                  <span className="cfg-ctl">
                    <div className="seg cfg-seg">
                      <button className={cfg.artifacts.enabled ? 'on' : ''} onClick={() => setCfg({ ...cfg, artifacts: { ...cfg.artifacts, enabled: true } })}>on</button>
                      <button className={!cfg.artifacts.enabled ? 'on' : ''} onClick={() => setCfg({ ...cfg, artifacts: { ...cfg.artifacts, enabled: false } })}>off</button>
                    </div>
                  </span>
                </div>
                <div className={cfg.artifacts.enabled ? undefined : 'cfg-off'}>
                  <div className="setting-row">
                    <div>
                      <div className="s-label">Artifacts Space</div>
                      <div className="s-help">Created by the first agent that needs it.</div>
                    </div>
                    <span className="cfg-ctl">
                      <input
                        className="cfg-input mono"
                        placeholder={cfg.defaultArtifactsSpace || 'user/agent-artifacts'}
                        value={cfg.artifacts.space}
                        onChange={(e) => setCfg({ ...cfg, artifacts: { ...cfg.artifacts, space: e.target.value } })}
                      />
                    </span>
                  </div>
                  <div className="setting-row">
                    <div>
                      <div className="s-label">Default visibility</div>
                      <div className="s-help">Whether new artifact pages are visible to anyone with the link.</div>
                    </div>
                    <span className="cfg-ctl">
                      <div className="seg cfg-seg">
                        <button className={cfg.artifacts.visibility === 'public' ? 'on' : ''} onClick={() => setCfg({ ...cfg, artifacts: { ...cfg.artifacts, visibility: 'public' } })}>public</button>
                        <button className={cfg.artifacts.visibility === 'private' ? 'on' : ''} onClick={() => setCfg({ ...cfg, artifacts: { ...cfg.artifacts, visibility: 'private' } })}>private</button>
                      </div>
                    </span>
                  </div>
                </div>
                <div className="setting-row">
                  <div>
                    <div className="s-label">Ask before HF Jobs above</div>
                    <div className="s-help">Agents run expensive compute (GPU, long batch work) as HF Jobs. Above this estimated cost they must ask you first. 0 means always ask.</div>
                  </div>
                  <span className="cfg-ctl">
                    <span className="mono cfg-usd">$</span>
                    <input
                      className="cfg-input cfg-num mono"
                      type="number" min={0} step={1}
                      value={cfg.jobs.askAboveUsd}
                      onChange={(e) => setCfg({ ...cfg, jobs: { askAboveUsd: Math.max(0, Number(e.target.value) || 0) } })}
                    />
                  </span>
                </div>

                <div className="setting-row">
                  <div>
                    <div className="s-label">Archive inactive sessions</div>
                    <div className="s-help">Sessions with no activity beyond this window disappear from the sidebar and overview. Nothing is deleted; the "archived" checkbox in the sidebar brings them back.</div>
                  </div>
                  <span className="cfg-ctl">
                    <div className="seg cfg-seg">
                      {(['week', 'month', 'never'] as const).map((a) => (
                        <button key={a} className={cfg.archive.after === a ? 'on' : ''} onClick={() => setCfg({ ...cfg, archive: { after: a } })}>
                          {a === 'week' ? '1 week' : a === 'month' ? '1 month' : 'never'}
                        </button>
                      ))}
                    </div>
                  </span>
                </div>
              </>
            )}

            <PushRow />

            <div className="setting-row" style={{ marginTop: 12 }}>
              <div>
                <div className="s-label">Update CLIs</div>
                <div className="s-help">
                  Factory-reboots the Space to reinstall every CLI at its latest version and relaunch. Sessions survive on the bucket.
                </div>
                {!info?.canRelaunch && (
                  <div className="s-help" style={{ marginTop: 6 }}>
                    This button needs a write-scoped <span className="mono">HF_TOKEN</span> Space secret. Without one,
                    update manually: open your Space's <b>Settings</b> tab on Hugging Face and press{' '}
                    <b>Factory reboot</b> — same effect.
                  </div>
                )}
              </div>
              {!info?.canRelaunch ? (
                <button className="btn-ghost" disabled title="Add a write-scoped HF_TOKEN secret to the Space to enable"><RefreshGlyph /> Relaunch &amp; update</button>
              ) : relaunch.confirm ? (
                <span className="confirm-del">
                  <span className="s-muted">Rebuild now?</span>
                  <button className="btn-primary" disabled={relaunch.busy} onClick={doRelaunch}>{relaunch.busy ? '…' : 'Relaunch'}</button>
                  <button className="btn-ghost" onClick={() => setRelaunch({})}>Cancel</button>
                </span>
              ) : (
                <button className="btn-ghost" onClick={() => setRelaunch({ confirm: true })}><RefreshGlyph /> Relaunch &amp; update</button>
              )}
            </div>
            {relaunch.msg && <div className="s-help" style={{ marginTop: 6 }}>{relaunch.msg}</div>}

            <h3>Space</h3>
            {info?.bucketUnverified && (
              <div className="s-warn">
                Can’t verify your storage bucket is private — without a write-scoped <span className="mono">HF_TOKEN</span> the
                app can’t discover which bucket is mounted. A public bucket exposes everything agents saved (including
                credentials). Double-check the bucket is <b>Private</b>, or add an <span className="mono">HF_TOKEN</span> secret to enable the automatic check.
              </div>
            )}
            <div className="kv">
              <div><span>Space</span><b>{info?.spaceId || '—'}</b></div>
              <div><span>Durable storage</span><b className="mono">{info?.dataDir || '—'}</b></div>
              <div><span>Home</span><b className="mono">{info?.home || '—'}</b></div>
              <div><span>tmux</span><b>{info?.tmux ? 'on' : 'off'}</b></div>
            </div>

          </div>
        )}

        {page === 'usage' && (
          <div className="settings-page wide">
            <h2>Usage</h2>
            <UsagePanel />
          </div>
        )}

        {page === 'skills' && (
          <div className="settings-page wide">
            <h2>Skills</h2>
            <p className="s-help">Reusable markdown/text skills. Saved skills are published as <span className="mono">SKILL.md</span> to every agent (Claude, Codex, Gemini, opencode, Hermes) and available in all new sessions. View renders markdown; Edit is plain text.</p>
            <SkillsEditor />
          </div>
        )}
      </div>
    </div>
  );
}