agent-manager-template / web /src /components /SettingsView.tsx
lvwerra's picture
lvwerra HF Staff
Pre-release hardening
ece21ed
Raw
History Blame Contribute Delete
14.9 kB
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 &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>
)}
<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>
);
}