Spaces:
Running
Running
| 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, | |
| }: { | |
| page: Page; | |
| onPage: (p: Page) => void; | |
| onClose: () => void; | |
| theme: 'light' | 'dark'; | |
| onToggleTheme: () => void; | |
| clis: Cli[]; | |
| info: Info | null; | |
| onShowWelcome?: () => 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]); | |
| 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> | |
| )} | |
| <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 & 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> | |
| )} | |
| <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 & 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 & 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> | |
| ); | |
| } | |