Feedback sweep: spinners on every async button, toasts for action outcomes, top activity bar, loading states on every page, terminal connecting overlay, dashboard save indicator, task pane pinned above the package list with auto-scroll and completion toast

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-17 18:04:45 -07:00
co-authored by Claude Fable 5.1
parent d08b246b5f
commit 724f30c02b
13 changed files with 261 additions and 93 deletions
+21 -12
View File
@@ -8,6 +8,7 @@ import Services from './Services'
import Cron from './Cron'
import Updates from './Updates'
import Power from './Power'
import { Btn, NetBar, Toasts, toast } from './ui'
const GROUPS: Group[] = [
{ id: 'dashboard', label: 'Dashboard', icon: '◔', page: true },
@@ -31,6 +32,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
const [view, setView] = useState<View>('dashboard')
const [drawer, setDrawer] = useState(false)
const [online, setOnline] = useState(true)
const [creating, setCreating] = useState(false)
const handles = useRef<Record<string, TermHandle>>({})
const reload = useCallback(async () => {
@@ -42,21 +44,26 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
useEffect(() => { reload() }, [reload])
async function create() {
const t = await api.createTerminal()
await reload()
setActive(t.id)
setView('terminal')
setDrawer(false)
setCreating(true)
try {
const t = await api.createTerminal()
await reload()
setActive(t.id)
setView('terminal')
setDrawer(false)
} catch (e) { toast(`new terminal — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
finally { setCreating(false) }
}
async function rename(t: Term) {
const name = window.prompt('Rename terminal', t.name)
if (name && name.trim() && name !== t.name) { await api.renameTerminal(t.id, name.trim()); await reload() }
if (name && name.trim() && name !== t.name) {
try { await api.renameTerminal(t.id, name.trim()); await reload() } catch (e) { toast(`rename — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
}
}
async function close(t: Term) {
if (t.running && !window.confirm(`Close "${t.name}"? The shell will be hung up.`)) return
await api.deleteTerminal(t.id)
delete handles.current[t.id]
await reload()
try { await api.deleteTerminal(t.id); delete handles.current[t.id]; await reload() }
catch (e) { toast(`close — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
}
async function logout() { await api.logout(); onLoggedOut() }
@@ -65,6 +72,8 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
return (
<div className={'app' + (drawer ? ' drawer-open' : '')}>
<NetBar />
<Toasts />
<header className="header">
<button className="icon-btn menu" onClick={() => setDrawer(!drawer)} aria-label="menu">☰</button>
<span className="brand">Ham Lab</span>
@@ -73,11 +82,11 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
<span className="stat" title="load average">{me.load}</span>
<span className={'dot ' + (online ? 'ok' : 'bad')} title={online ? 'connected' : 'disconnected'} />
<Power host={me.host} rebootNeeded={me.reboot_needed} />
<button className="link" onClick={logout}>logout</button>
<Btn className="link" onClick={logout}>logout</Btn>
</header>
<div className="body">
<Rail groups={GROUPS} terms={terms} active={active} view={view}
<Rail groups={GROUPS} terms={terms} active={active} view={view} creating={creating}
onSelect={(id) => { setActive(id); setView('terminal'); setDrawer(false) }}
onView={(id) => { setView(id as View); setDrawer(false) }}
onNew={create} onRename={rename} onClose={close} />
@@ -91,7 +100,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
{termView && terms.length === 0 && (
<div className="center muted">
<div>No terminals.</div>
<button className="primary" onClick={create}>+ New Terminal</button>
<Btn className="primary" busy={creating} onClick={create}>+ New Terminal</Btn>
</div>
)}
{terms.map((t) => (