From f795ff2b4716e81c95ac4aa44474dd31c82a89f9 Mon Sep 17 00:00:00 2001 From: AetherForge Date: Sun, 7 Jun 2026 18:22:27 -0700 Subject: [PATCH] Shorten notification dwell time and add dismissible toast stack. --- server/web/src/App.tsx | 7 + .../src/components/Fleet/AccessDepthPanel.tsx | 2 +- .../web/src/components/Fleet/FleetPanels.css | 34 ++++ .../web/src/components/Fleet/FleetPanels.tsx | 81 +++++++++- .../web/src/components/Toast/ToastStack.css | 148 ++++++++++++++++++ .../src/components/Toast/ToastStack.test.tsx | 51 ++++++ .../web/src/components/Toast/ToastStack.tsx | 52 ++++++ .../src/components/Toast/WsEventToasts.tsx | 71 +++++++++ server/web/src/components/components.test.tsx | 15 +- server/web/src/context/ToastContext.test.tsx | 99 ++++++++++++ server/web/src/context/ToastContext.tsx | 123 +++++++++++++++ server/web/src/pages/SettingsPage.tsx | 2 +- 12 files changed, 677 insertions(+), 8 deletions(-) create mode 100644 server/web/src/components/Toast/ToastStack.css create mode 100644 server/web/src/components/Toast/ToastStack.test.tsx create mode 100644 server/web/src/components/Toast/ToastStack.tsx create mode 100644 server/web/src/components/Toast/WsEventToasts.tsx create mode 100644 server/web/src/context/ToastContext.test.tsx create mode 100644 server/web/src/context/ToastContext.tsx diff --git a/server/web/src/App.tsx b/server/web/src/App.tsx index a76d0d5..76eb653 100644 --- a/server/web/src/App.tsx +++ b/server/web/src/App.tsx @@ -11,6 +11,9 @@ import { ForgeProvider } from './context/ForgeContext'; import { MatrixRainProvider } from './context/MatrixRainContext'; import SoundBridge from './components/Sound/SoundBridge'; import GlobalMusicPlayer from './components/GlobalMusicPlayer'; +import { ToastProvider } from './context/ToastContext'; +import ToastStack from './components/Toast/ToastStack'; +import WsEventToasts from './components/Toast/WsEventToasts'; import AgentsPage from './pages/AgentsPage'; import CruciblePage from './pages/CruciblePage'; @@ -43,9 +46,12 @@ function App() { + + + @@ -81,6 +87,7 @@ function App() { + diff --git a/server/web/src/components/Fleet/AccessDepthPanel.tsx b/server/web/src/components/Fleet/AccessDepthPanel.tsx index 9348625..fb34500 100644 --- a/server/web/src/components/Fleet/AccessDepthPanel.tsx +++ b/server/web/src/components/Fleet/AccessDepthPanel.tsx @@ -100,7 +100,7 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) { }); setElevationFlash(text); if (flashTimerRef.current != null) window.clearTimeout(flashTimerRef.current); - flashTimerRef.current = window.setTimeout(() => setElevationFlash(null), 6000); + flashTimerRef.current = window.setTimeout(() => setElevationFlash(null), 5000); }, [latestMessage, agent.id]); useEffect(() => { diff --git a/server/web/src/components/Fleet/FleetPanels.css b/server/web/src/components/Fleet/FleetPanels.css index 5d32b20..3062eb4 100644 --- a/server/web/src/components/Fleet/FleetPanels.css +++ b/server/web/src/components/Fleet/FleetPanels.css @@ -3,6 +3,21 @@ flex-direction: column; gap: 0.5rem; margin-bottom: 1rem; + max-height: min(32vh, 14rem); + overflow: hidden; +} + +.alert-banner-clear-all { + align-self: flex-end; + border: 1px solid rgba(255, 176, 32, 0.35); + background: rgba(0, 0, 0, 0.35); + color: var(--neon-amber, #ffb020); + font-family: var(--font-tech, monospace); + font-size: 0.62rem; + letter-spacing: 0.06em; + padding: 0.2rem 0.55rem; + border-radius: 4px; + cursor: pointer; } .alert-banner { @@ -37,6 +52,25 @@ opacity: 0.6; } +.alert-banner-dismiss { + flex-shrink: 0; + width: 1.35rem; + height: 1.35rem; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-muted, #8899aa); + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + padding: 0; +} + +.alert-banner-dismiss:hover { + color: var(--text-primary, #e8edf5); + background: rgba(255, 255, 255, 0.08); +} + .pool-status-grid { display: flex; flex-direction: column; diff --git a/server/web/src/components/Fleet/FleetPanels.tsx b/server/web/src/components/Fleet/FleetPanels.tsx index bd42cef..3424371 100644 --- a/server/web/src/components/Fleet/FleetPanels.tsx +++ b/server/web/src/components/Fleet/FleetPanels.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '../../api/client'; import NeonCard from '../NeonCard/NeonCard'; import type { Agent, FleetAlert, PoolStatus, AIActivityEntry } from '../../types'; @@ -8,17 +8,88 @@ import { formatHashrate } from '../../help/fleetFilters'; import { HelpTip } from '../HelpTip'; import './FleetPanels.css'; +const MAX_ALERT_BANNERS = 5; +const ALERT_DURATION_MS: Record = { + warn: 6000, + error: 10000, +}; + export function AlertBanner({ alerts }: { alerts: FleetAlert[] }) { - if (alerts.length === 0) return null; + const [dismissed, setDismissed] = useState>(() => new Set()); + const [expired, setExpired] = useState>(() => new Set()); + const timersRef = useRef>>(new Map()); + + const dismiss = useCallback((id: string) => { + const timer = timersRef.current.get(id); + if (timer) { + clearTimeout(timer); + timersRef.current.delete(id); + } + setDismissed((prev) => new Set(prev).add(id)); + }, []); + + const clearAll = useCallback(() => { + for (const timer of timersRef.current.values()) clearTimeout(timer); + timersRef.current.clear(); + setDismissed((prev) => { + const next = new Set(prev); + for (const a of alerts) next.add(a.id); + return next; + }); + }, [alerts]); + + useEffect(() => { + const activeIds = new Set(alerts.map((a) => a.id)); + for (const [id, timer] of timersRef.current) { + if (!activeIds.has(id)) { + clearTimeout(timer); + timersRef.current.delete(id); + } + } + for (const alert of alerts) { + if (dismissed.has(alert.id) || expired.has(alert.id) || timersRef.current.has(alert.id)) continue; + const timer = setTimeout(() => { + timersRef.current.delete(alert.id); + setExpired((prev) => new Set(prev).add(alert.id)); + }, ALERT_DURATION_MS[alert.level]); + timersRef.current.set(alert.id, timer); + } + }, [alerts, dismissed, expired]); + + useEffect(() => () => { + for (const timer of timersRef.current.values()) clearTimeout(timer); + timersRef.current.clear(); + }, []); + + const visible = useMemo( + () => alerts.filter((a) => !dismissed.has(a.id) && !expired.has(a.id)).slice(0, MAX_ALERT_BANNERS), + [alerts, dismissed, expired], + ); + + if (visible.length === 0) return null; + return ( -
- {alerts.slice(0, 5).map((a) => ( -
+
+ {visible.length > 1 && ( + + )} + {visible.map((a) => ( +
{a.type.replace(/_/g, ' ').toUpperCase()} {a.message} {a.timestamp ? new Date(a.timestamp).toLocaleTimeString() : ''} +
))}
diff --git a/server/web/src/components/Toast/ToastStack.css b/server/web/src/components/Toast/ToastStack.css new file mode 100644 index 0000000..53ff4fb --- /dev/null +++ b/server/web/src/components/Toast/ToastStack.css @@ -0,0 +1,148 @@ +.toast-stack { + position: fixed; + top: 3.25rem; + right: 1rem; + z-index: 9990; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.35rem; + max-width: min(22rem, calc(100vw - 2rem)); + pointer-events: none; +} + +.toast-clear-all { + pointer-events: auto; + border: 1px solid rgba(0, 232, 245, 0.35); + background: rgba(0, 0, 0, 0.72); + color: var(--neon-cyan, #00e8f5); + font-family: var(--font-tech, monospace); + font-size: 0.62rem; + letter-spacing: 0.06em; + padding: 0.2rem 0.55rem; + border-radius: 4px; + cursor: pointer; +} + +.toast-clear-all:hover { + background: rgba(0, 232, 245, 0.12); +} + +.toast-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.45rem; + max-height: min(40vh, 18rem); + overflow: hidden; + width: 100%; +} + +.toast-item { + pointer-events: auto; + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.55rem 0.65rem; + border-radius: 6px; + border: 1px solid rgba(0, 232, 245, 0.28); + background: rgba(8, 12, 18, 0.92); + box-shadow: 0 4px 18px rgba(0, 0, 0, 0.45); + font-size: 0.82rem; + animation: toast-in 0.2s ease-out; +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateX(0.75rem); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.toast-info { + border-color: rgba(0, 232, 245, 0.35); +} + +.toast-success { + border-color: rgba(57, 255, 20, 0.4); + background: rgba(8, 18, 10, 0.94); +} + +.toast-warn { + border-color: rgba(255, 176, 32, 0.45); + background: rgba(18, 14, 8, 0.94); +} + +.toast-error { + border-color: rgba(255, 60, 80, 0.5); + background: rgba(18, 8, 10, 0.94); +} + +.toast-body { + flex: 1; + display: flex; + gap: 0.55rem; + min-width: 0; +} + +.toast-level { + font-size: 0.58rem; + letter-spacing: 0.08em; + opacity: 0.8; + flex-shrink: 0; + padding-top: 0.1rem; +} + +.toast-text { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} + +.toast-message { + line-height: 1.35; + word-break: break-word; +} + +.toast-detail { + font-size: 0.72rem; + opacity: 0.72; + word-break: break-word; +} + +.toast-dismiss { + flex-shrink: 0; + width: 1.35rem; + height: 1.35rem; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-muted, #8899aa); + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + padding: 0; +} + +.toast-dismiss:hover { + color: var(--text-primary, #e8edf5); + background: rgba(255, 255, 255, 0.08); +} + +@media (max-width: 768px) { + .toast-stack { + top: auto; + bottom: 4.5rem; + right: 0.65rem; + left: 0.65rem; + max-width: none; + align-items: stretch; + } +} diff --git a/server/web/src/components/Toast/ToastStack.test.tsx b/server/web/src/components/Toast/ToastStack.test.tsx new file mode 100644 index 0000000..59a3e5b --- /dev/null +++ b/server/web/src/components/Toast/ToastStack.test.tsx @@ -0,0 +1,51 @@ +/** + * @vitest-environment happy-dom + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { ToastProvider, useToast } from '../../context/ToastContext'; +import ToastStack from './ToastStack'; + +function Seed({ count }: { count: number }) { + const { showToast } = useToast(); + return ( + + ); +} + +describe('ToastStack', () => { + afterEach(() => cleanup()); + + it('renders dismiss buttons on each toast', () => { + render( + + + + , + ); + fireEvent.click(screen.getByText('seed')); + expect(screen.getByText('msg 0')).toBeInTheDocument(); + fireEvent.click(screen.getByLabelText('Dismiss notification')); + expect(screen.queryByTestId('toast-stack')).toBeNull(); + }); + + it('shows clear all when multiple toasts are visible', () => { + render( + + + + , + ); + fireEvent.click(screen.getByText('seed')); + expect(screen.getByLabelText('Clear all notifications')).toBeInTheDocument(); + fireEvent.click(screen.getByLabelText('Clear all notifications')); + expect(screen.queryByTestId('toast-stack')).toBeNull(); + }); +}); diff --git a/server/web/src/components/Toast/ToastStack.tsx b/server/web/src/components/Toast/ToastStack.tsx new file mode 100644 index 0000000..80ad6a2 --- /dev/null +++ b/server/web/src/components/Toast/ToastStack.tsx @@ -0,0 +1,52 @@ +import { useToast } from '../../context/ToastContext'; +import type { ToastLevel } from '../../context/ToastContext'; +import './ToastStack.css'; + +const LEVEL_LABEL: Record = { + info: 'INFO', + success: 'OK', + warn: 'WARN', + error: 'ERROR', +}; + +export default function ToastStack() { + const { toasts, dismissToast, clearAllToasts } = useToast(); + + if (toasts.length === 0) return null; + + return ( +
+ {toasts.length > 1 && ( + + )} +
    + {toasts.map((toast) => ( +
  • +
    + {LEVEL_LABEL[toast.level]} +
    + {toast.message} + {toast.detail ? {toast.detail} : null} +
    +
    + +
  • + ))} +
+
+ ); +} diff --git a/server/web/src/components/Toast/WsEventToasts.tsx b/server/web/src/components/Toast/WsEventToasts.tsx new file mode 100644 index 0000000..d018b47 --- /dev/null +++ b/server/web/src/components/Toast/WsEventToasts.tsx @@ -0,0 +1,71 @@ +import { useEffect, useRef } from 'react'; +import { useToast } from '../../context/ToastContext'; +import { useWebSocket } from '../../hooks/useWebSocket'; +import type { FleetAlert, WSMessage } from '../../types'; +import type { WSCommandResult } from '../../types/ws'; + +const SILENT_CMD_ACTIONS = new Set(['get_log', 'wg_status', 'mesh_status']); +const CMD_RESULT_MIN_MS = 600; + +/** + * Ephemeral dashboard toasts for high-signal WebSocket events. + */ +export default function WsEventToasts() { + const { showToast } = useToast(); + const { latestMessage } = useWebSocket(); + const lastMsgRef = useRef(null); + const lastCmdAt = useRef(0); + + useEffect(() => { + if (!latestMessage || latestMessage === lastMsgRef.current) return; + lastMsgRef.current = latestMessage; + + switch (latestMessage.type) { + case 'agent_online': { + const agent = latestMessage.payload as { name?: string; id?: string }; + const label = agent.name ?? agent.id?.slice(0, 8) ?? 'Agent'; + showToast(`${label} online`, { level: 'success' }); + break; + } + case 'agent_offline': { + const { agent_id } = latestMessage.payload as { agent_id: string }; + showToast(`${agent_id.slice(0, 8)} offline`, { level: 'warn' }); + break; + } + case 'fleet_alert': { + const alert = latestMessage.payload as FleetAlert; + showToast(alert.message, { + level: alert.level === 'error' ? 'error' : 'warn', + detail: alert.type?.replace(/_/g, ' '), + }); + break; + } + case 'command_result': { + const p = latestMessage.payload as WSCommandResult; + if (p.action && SILENT_CMD_ACTIONS.has(p.action)) break; + const now = Date.now(); + if (now - lastCmdAt.current < CMD_RESULT_MIN_MS) break; + lastCmdAt.current = now; + if (p.success) break; + const who = p.agent_id?.slice(0, 8) ?? 'agent'; + showToast(`${p.action ?? 'command'} failed on ${who}`, { + level: 'error', + detail: p.message?.slice(0, 120), + }); + break; + } + case 'agent_capabilities': { + const { agent_id } = latestMessage.payload as { agent_id: string }; + showToast('Agent capabilities refreshed', { + level: 'success', + detail: agent_id?.slice(0, 8), + }); + break; + } + default: + break; + } + }, [latestMessage, showToast]); + + return null; +} diff --git a/server/web/src/components/components.test.tsx b/server/web/src/components/components.test.tsx index dfe5a49..dae18e0 100644 --- a/server/web/src/components/components.test.tsx +++ b/server/web/src/components/components.test.tsx @@ -623,11 +623,24 @@ describe('FleetPanels', () => { it('AlertBanner shows up to five alerts', () => { const alerts = [ - { id: '1', level: 'warning', type: 'offline', message: 'Node down', timestamp: '2026-05-30T12:00:00Z' }, + { id: '1', level: 'warn', type: 'offline', message: 'Node down', timestamp: '2026-05-30T12:00:00Z' }, ]; render(); expect(screen.getByText('OFFLINE')).toBeInTheDocument(); expect(screen.getByText('Node down')).toBeInTheDocument(); + expect(screen.getByLabelText('Dismiss alert')).toBeInTheDocument(); + }); + + it('AlertBanner dismisses and offers clear all for multiple alerts', () => { + const alerts = [ + { id: '1', level: 'warn', type: 'offline', message: 'A', timestamp: '' }, + { id: '2', level: 'error', type: 'pool_down', message: 'B', timestamp: '' }, + ]; + render(); + expect(screen.getByText('Clear all alerts')).toBeInTheDocument(); + fireEvent.click(screen.getAllByLabelText('Dismiss alert')[0]); + expect(screen.queryByText('A')).toBeNull(); + expect(screen.getByText('B')).toBeInTheDocument(); }); it('PoolStatusPanel empty hint', () => { diff --git a/server/web/src/context/ToastContext.test.tsx b/server/web/src/context/ToastContext.test.tsx new file mode 100644 index 0000000..dd62b94 --- /dev/null +++ b/server/web/src/context/ToastContext.test.tsx @@ -0,0 +1,99 @@ +/** + * @vitest-environment happy-dom + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, act, cleanup } from '@testing-library/react'; +import { ToastProvider, useToast, TOAST_DURATION_MS, MAX_VISIBLE_TOASTS } from './ToastContext'; + +function Probe() { + const { toasts, showToast, dismissToast, clearAllToasts } = useToast(); + return ( +
+ {toasts.length} + + + + +
+ ); +} + +describe('ToastContext', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it('uses shorter default durations for info than error', () => { + expect(TOAST_DURATION_MS.info).toBeLessThan(TOAST_DURATION_MS.error); + expect(TOAST_DURATION_MS.info).toBeGreaterThanOrEqual(4000); + expect(TOAST_DURATION_MS.info).toBeLessThanOrEqual(6000); + }); + + it('auto-dismisses info toasts and keeps errors longer', () => { + render( + + + , + ); + fireEvent.click(screen.getByText('info')); + fireEvent.click(screen.getByText('error')); + expect(screen.getByTestId('count').textContent).toBe('2'); + + act(() => { + vi.advanceTimersByTime(TOAST_DURATION_MS.info); + }); + expect(screen.getByTestId('count').textContent).toBe('1'); + + act(() => { + vi.advanceTimersByTime(TOAST_DURATION_MS.error - TOAST_DURATION_MS.info); + }); + expect(screen.getByTestId('count').textContent).toBe('0'); + }); + + it('caps visible toasts', () => { + function Spam() { + const { showToast, toasts } = useToast(); + return ( + + ); + } + render( + + + , + ); + fireEvent.click(screen.getByText(/spam/)); + expect(screen.getByText(/spam/)).toHaveTextContent(String(MAX_VISIBLE_TOASTS)); + }); + + it('dismisses and clears all manually', () => { + render( + + + , + ); + fireEvent.click(screen.getByText('info')); + fireEvent.click(screen.getByText('error')); + fireEvent.click(screen.getByText('dismiss')); + expect(screen.getByTestId('count').textContent).toBe('1'); + fireEvent.click(screen.getByText('clear')); + expect(screen.getByTestId('count').textContent).toBe('0'); + }); +}); diff --git a/server/web/src/context/ToastContext.tsx b/server/web/src/context/ToastContext.tsx new file mode 100644 index 0000000..5dbb9ef --- /dev/null +++ b/server/web/src/context/ToastContext.tsx @@ -0,0 +1,123 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +export type ToastLevel = 'info' | 'success' | 'warn' | 'error'; + +export interface Toast { + id: string; + level: ToastLevel; + message: string; + detail?: string; + createdAt: number; + durationMs: number; +} + +export interface ShowToastOptions { + level?: ToastLevel; + detail?: string; + /** Override default auto-dismiss; 0 = stay until dismissed */ + durationMs?: number; +} + +export const TOAST_DURATION_MS: Record = { + info: 5000, + success: 5000, + warn: 6000, + error: 10000, +}; + +export const MAX_VISIBLE_TOASTS = 5; + +let toastCounter = 0; +function nextToastId(): string { + toastCounter += 1; + return `toast-${toastCounter}`; +} + +export interface ToastContextValue { + toasts: Toast[]; + showToast: (message: string, options?: ShowToastOptions) => string; + dismissToast: (id: string) => void; + clearAllToasts: () => void; +} + +const ToastContext = createContext({ + toasts: [], + showToast: () => '', + dismissToast: () => {}, + clearAllToasts: () => {}, +}); + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + const timersRef = useRef>>(new Map()); + + const dismissToast = useCallback((id: string) => { + const timer = timersRef.current.get(id); + if (timer) { + clearTimeout(timer); + timersRef.current.delete(id); + } + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + const clearAllToasts = useCallback(() => { + for (const timer of timersRef.current.values()) clearTimeout(timer); + timersRef.current.clear(); + setToasts([]); + }, []); + + const scheduleDismiss = useCallback( + (toast: Toast) => { + if (toast.durationMs <= 0) return; + const timer = setTimeout(() => dismissToast(toast.id), toast.durationMs); + timersRef.current.set(toast.id, timer); + }, + [dismissToast], + ); + + const showToast = useCallback( + (message: string, options?: ShowToastOptions) => { + const level = options?.level ?? 'info'; + const durationMs = options?.durationMs ?? TOAST_DURATION_MS[level]; + const toast: Toast = { + id: nextToastId(), + level, + message, + detail: options?.detail, + createdAt: Date.now(), + durationMs, + }; + setToasts((prev) => [toast, ...prev].slice(0, MAX_VISIBLE_TOASTS)); + scheduleDismiss(toast); + return toast.id; + }, + [scheduleDismiss], + ); + + useEffect(() => { + const timers = timersRef.current; + return () => { + for (const timer of timers.values()) clearTimeout(timer); + timers.clear(); + }; + }, []); + + const value = useMemo( + () => ({ toasts, showToast, dismissToast, clearAllToasts }), + [toasts, showToast, dismissToast, clearAllToasts], + ); + + return {children}; +} + +export function useToast(): ToastContextValue { + return useContext(ToastContext); +} diff --git a/server/web/src/pages/SettingsPage.tsx b/server/web/src/pages/SettingsPage.tsx index 8dacb45..885fee0 100644 --- a/server/web/src/pages/SettingsPage.tsx +++ b/server/web/src/pages/SettingsPage.tsx @@ -302,7 +302,7 @@ export default function SettingsPage() { setAlertTestMsg('Test failed: ' + (e instanceof Error ? e.message : String(e))); } finally { setTestingAlerts(false); - setTimeout(() => setAlertTestMsg(''), 12000); + setTimeout(() => setAlertTestMsg(''), 6000); } };