feat: alive UI wave, galaxy presence, spread and fleet enhancements
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

Dashboard ambient layer, comrade presence, Mission Deck and War Room, Emberwake supply chain, spread/docs publishing, fleet policy and modules API, CI docker mining, and refreshed USB pack.
This commit is contained in:
AetherForge
2026-06-04 22:36:17 -07:00
parent 1551bd5dad
commit a32860b0d9
154 changed files with 17383 additions and 601 deletions

View File

@@ -9,8 +9,12 @@ type AmbientMusicContextValue = {
enabled: boolean;
playing: boolean;
volume: number;
pageIntensity: number;
modalDuckActive: boolean;
setEnabled: (v: boolean) => void;
setVolume: (v: number) => void;
setPageIntensity: (v: number) => void;
registerModalDuck: () => () => void;
togglePlay: () => void;
};
@@ -20,6 +24,8 @@ export function AmbientMusicProvider({ children }: { children: React.ReactNode }
const [enabled, setEnabledState] = useState(loadBgmEnabled);
const [playing, setPlaying] = useState(() => ambientMusicPlayer.isPlaying());
const [volume, setVolumeState] = useState(loadBgmVolume);
const [pageIntensity, setPageIntensityState] = useState(() => ambientMusicPlayer.getPageIntensity());
const [modalDuckActive, setModalDuckActive] = useState(() => ambientMusicPlayer.isModalDuckActive());
const setEnabled = useCallback((v: boolean) => {
ambientMusicPlayer.setEnabled(v);
@@ -32,6 +38,20 @@ export function AmbientMusicProvider({ children }: { children: React.ReactNode }
setVolumeState(ambientMusicPlayer.getVolume());
}, []);
const setPageIntensity = useCallback((v: number) => {
ambientMusicPlayer.setPageIntensity(v);
setPageIntensityState(ambientMusicPlayer.getPageIntensity());
}, []);
const registerModalDuck = useCallback(() => {
setModalDuckActive(true);
const unregister = ambientMusicPlayer.registerModalDuck();
return () => {
unregister();
setModalDuckActive(ambientMusicPlayer.isModalDuckActive());
};
}, []);
const togglePlay = useCallback(() => {
ambientMusicPlayer.unlock();
ambientMusicPlayer.togglePlay();
@@ -59,8 +79,19 @@ export function AmbientMusicProvider({ children }: { children: React.ReactNode }
}, []);
const value = useMemo(
() => ({ enabled, playing, volume, setEnabled, setVolume, togglePlay }),
[enabled, playing, volume, setEnabled, setVolume, togglePlay]
() => ({
enabled,
playing,
volume,
pageIntensity,
modalDuckActive,
setEnabled,
setVolume,
setPageIntensity,
registerModalDuck,
togglePlay,
}),
[enabled, playing, volume, pageIntensity, modalDuckActive, setEnabled, setVolume, setPageIntensity, registerModalDuck, togglePlay]
);
return <AmbientMusicContext.Provider value={value}>{children}</AmbientMusicContext.Provider>;
@@ -70,8 +101,12 @@ const noopAmbient: AmbientMusicContextValue = {
enabled: false,
playing: false,
volume: 0,
pageIntensity: 1,
modalDuckActive: false,
setEnabled: () => {},
setVolume: () => {},
setPageIntensity: () => {},
registerModalDuck: () => () => {},
togglePlay: () => {},
};
@@ -79,3 +114,12 @@ export function useAmbientMusic() {
const ctx = useContext(AmbientMusicContext);
return ctx ?? noopAmbient;
}
/** Duck ambient music while `open` is true; swells back when closed. */
export function useModalAmbientDuck(open: boolean) {
const { registerModalDuck } = useAmbientMusic();
useEffect(() => {
if (!open) return;
return registerModalDuck();
}, [open, registerModalDuck]);
}

View File

@@ -0,0 +1,138 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { getStoredUsername } from '../api/auth';
import { useWebSocketContext } from './WebSocketContext';
import {
comradesOnPage,
initialPresenceState,
onlineComrades,
reducePresence,
type ComradePresence,
type NotesTyping,
} from './presenceReducer';
interface PresenceContextValue {
selfUser: string | null;
comrades: ComradePresence[];
othersOnline: boolean;
comradesHere: (page: string) => ComradePresence[];
notesTyping: NotesTyping | null;
sendNotesTyping: (active: boolean) => void;
}
const PresenceContext = createContext<PresenceContextValue>({
selfUser: null,
comrades: [],
othersOnline: false,
comradesHere: () => [],
notesTyping: null,
sendNotesTyping: () => {},
});
const TYPING_STALE_MS = 4000;
export function PresenceProvider({ children }: { children: React.ReactNode }) {
const { isConnected, latestMessage, sendDashboardMessage } = useWebSocketContext();
const location = useLocation();
const [state, dispatch] = useReducer(reducePresence, initialPresenceState);
const lastPageRef = useRef('');
useEffect(() => {
dispatch({ type: 'set_self', user: getStoredUsername() });
const onAuth = () => dispatch({ type: 'set_self', user: getStoredUsername() });
window.addEventListener('aetherforge-auth', onAuth);
return () => window.removeEventListener('aetherforge-auth', onAuth);
}, []);
useEffect(() => {
if (!latestMessage) return;
switch (latestMessage.type) {
case 'presence_snapshot': {
const p = latestMessage.payload as { comrades?: ComradePresence[] };
if (Array.isArray(p?.comrades)) {
dispatch({ type: 'presence_snapshot', comrades: p.comrades });
}
break;
}
case 'presence_update': {
const p = latestMessage.payload as {
user?: string;
page?: string;
online?: boolean;
ts?: number;
};
if (p?.user) {
dispatch({
type: 'presence_update',
user: p.user,
page: p.page ?? '/dashboard',
online: p.online !== false,
ts: p.ts ?? Date.now(),
});
}
break;
}
case 'notes_typing': {
const p = latestMessage.payload as { user?: string; active?: boolean; ts?: number };
if (p?.user) {
dispatch({
type: 'notes_typing',
user: p.user,
active: !!p.active,
ts: p.ts ?? Date.now(),
});
}
break;
}
default:
break;
}
}, [latestMessage]);
useEffect(() => {
if (!isConnected) {
lastPageRef.current = '';
return;
}
const page = location.pathname || '/dashboard';
if (page === lastPageRef.current) return;
lastPageRef.current = page;
sendDashboardMessage('presence_page', { page });
}, [isConnected, location.pathname, sendDashboardMessage]);
useEffect(() => {
if (!state.notesTyping?.active) return;
const age = Date.now() - state.notesTyping.ts;
const delay = Math.max(0, TYPING_STALE_MS - age);
const t = setTimeout(() => dispatch({ type: 'clear_notes_typing' }), delay);
return () => clearTimeout(t);
}, [state.notesTyping]);
const sendNotesTyping = useCallback(
(active: boolean) => {
sendDashboardMessage('notes_typing', { active });
},
[sendDashboardMessage],
);
const comrades = useMemo(() => onlineComrades(state), [state]);
const comradesHere = useCallback((page: string) => comradesOnPage(state, page), [state]);
const value = useMemo(
() => ({
selfUser: state.selfUser,
comrades,
othersOnline: comrades.length > 0,
comradesHere,
notesTyping: state.notesTyping,
sendNotesTyping,
}),
[state.selfUser, comrades, comradesHere, state.notesTyping, sendNotesTyping],
);
return <PresenceContext.Provider value={value}>{children}</PresenceContext.Provider>;
}
export function usePresence(): PresenceContextValue {
return useContext(PresenceContext);
}

View File

@@ -32,6 +32,10 @@ export const SFX_INTERACTIVE_SELECTOR = [
'.pt-agent-card:not([style*="cursor: not-allowed"])',
'.endpoint-chip:not(:disabled)',
'.fleet-group-chip:not(:disabled)',
'.forge-mission-wizard-pill:not(:disabled)',
'.forge-mission-op-chip:not(:disabled)',
'.operator-interactive',
'.operator-interactive-btn',
].join(', ');
/** Elements that emit hover highlight SFX (debounced). */
@@ -39,6 +43,9 @@ export const HOVER_INTERACTIVE_SELECTOR = [
SFX_INTERACTIVE_SELECTOR,
'.neon-card',
'.card',
'.operator-deck-card',
'.operator-interactive',
'.operator-interactive-btn',
'a[href]:not([data-sfx="off"])',
].join(', ');

View File

@@ -27,6 +27,7 @@ describe('WebSocketContext', () => {
agentLogs: { 'agent-001-uuid': 'log line' },
commandResults: [{ agent_id: 'a1', action: 'pause', success: true, _seq: 1 }],
latestMessage: null,
sendDashboardMessage: () => {},
};
const wrapper = ({ children }: { children: React.ReactNode }) => (

View File

@@ -1,6 +1,6 @@
import React, { createContext, useContext } from 'react';
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
import type { WSCommandResult } from '../types/ws';
import type { WSCommandResult, WSPolicyAck } from '../types/ws';
/**
* WSCommandResult with a monotonic sequence number attached by the provider.
@@ -10,6 +10,8 @@ import type { WSCommandResult } from '../types/ws';
*/
export type SeqCommandResult = WSCommandResult & { _seq: number };
export type SeqPolicyAck = WSPolicyAck & { _seq: number };
export interface WebSocketContextValue {
isConnected: boolean;
agents: Agent[];
@@ -19,8 +21,10 @@ export interface WebSocketContextValue {
aiActivity: AIActivityEntry[];
agentLogs: Record<string, string>;
commandResults: SeqCommandResult[];
policyAcks: SeqPolicyAck[];
/** @deprecated Use commandResults instead. */
latestMessage: WSMessage | null;
sendDashboardMessage: (type: string, payload: Record<string, unknown>) => void;
}
export const WebSocketContext = createContext<WebSocketContextValue>({
@@ -32,7 +36,9 @@ export const WebSocketContext = createContext<WebSocketContextValue>({
aiActivity: [],
agentLogs: {},
commandResults: [],
policyAcks: [],
latestMessage: null,
sendDashboardMessage: () => {},
});
export function useWebSocketContext(): WebSocketContextValue {

View File

@@ -5,10 +5,12 @@ import type {
WSStatsUpdate,
WSCommandResult,
WSAgentLog,
WSPolicyAck,
} from '../types/ws';
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
import { WebSocketContext } from './WebSocketContext';
import type { SeqCommandResult } from './WebSocketContext';
import type { SeqPolicyAck } from './WebSocketContext';
import { authHeaders, getStoredAuth } from '../api/auth';
/**
@@ -28,9 +30,17 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
const [aiActivity, setAiActivity] = useState<AIActivityEntry[]>([]);
const [agentLogs, setAgentLogs] = useState<Record<string, string>>({});
const [commandResults, setCommandResults] = useState<SeqCommandResult[]>([]);
const [policyAcks, setPolicyAcks] = useState<SeqPolicyAck[]>([]);
const [latestMessage, setLatestMessage] = useState<WSMessage | null>(null);
// Monotonic counter so consumers can detect new entries even after the ring buffer trims old ones
const cmdSeqRef = useRef(0);
const policyAckSeqRef = useRef(0);
const sendDashboardMessage = useCallback((type: string, payload: Record<string, unknown>) => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type, payload }));
}, []);
const connect = useCallback(() => {
if (unmounted.current) return;
@@ -231,11 +241,35 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
}
break;
}
case 'policy_ack': {
const ack = msg.payload as WSPolicyAck;
policyAckSeqRef.current += 1;
setPolicyAcks((prev) => [...prev.slice(-49), { ...ack, _seq: policyAckSeqRef.current }]);
break;
}
case 'agent_log': {
const { agent_id, content } = msg.payload as WSAgentLog;
if (agent_id) setAgentLogs((prev) => ({ ...prev, [agent_id]: content }));
break;
}
case 'agent_capabilities': {
const { agent_id, capabilities } = msg.payload as {
agent_id: string;
capabilities: Agent['capabilities'];
};
if (!agent_id || !capabilities) break;
setAgents((prev) =>
prev.map((a) =>
a.id === agent_id
? {
...a,
capabilities: { ...a.capabilities, ...capabilities },
}
: a
)
);
break;
}
}
} catch (err) {
console.error('Failed to parse WebSocket message:', err);
@@ -263,7 +297,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
return (
<WebSocketContext.Provider value={{
isConnected, agents, recentShares, fleetAlerts, poolStatus,
aiActivity, agentLogs, commandResults, latestMessage,
aiActivity, agentLogs, commandResults, policyAcks, latestMessage, sendDashboardMessage,
}}>
{children}
</WebSocketContext.Provider>

View File

@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest';
import {
comradesOnPage,
initialPresenceState,
onlineComrades,
reducePresence,
} from './presenceReducer';
describe('reducePresence', () => {
it('tracks self user and excludes from comrades', () => {
let state = reducePresence(initialPresenceState, { type: 'set_self', user: 'india' });
state = reducePresence(state, {
type: 'presence_update',
user: 'india',
page: '/crucible',
online: true,
ts: 1,
});
state = reducePresence(state, {
type: 'presence_update',
user: 'comrade',
page: '/emberwake',
online: true,
ts: 2,
});
expect(onlineComrades(state).map((c) => c.user)).toEqual(['comrade']);
expect(state.comrades.comrade.page).toBe('/emberwake');
});
it('hydrates from presence_snapshot', () => {
const state = reducePresence(
{ ...initialPresenceState, selfUser: 'india' },
{
type: 'presence_snapshot',
comrades: [
{ user: 'india', page: '/dashboard', online: true, ts: 1 },
{ user: 'comrade', page: '/crucible', online: true, ts: 2 },
],
},
);
expect(onlineComrades(state)).toHaveLength(1);
expect(comradesOnPage(state, '/crucible')[0]?.user).toBe('comrade');
});
it('removes comrade on offline presence_update', () => {
let state = reducePresence(initialPresenceState, {
type: 'presence_update',
user: 'comrade',
page: '/forge',
online: true,
ts: 1,
});
state = reducePresence(state, {
type: 'presence_update',
user: 'comrade',
page: '',
online: false,
ts: 2,
});
expect(onlineComrades(state)).toHaveLength(0);
});
it('tracks notes_typing from other users only', () => {
let state = reducePresence(initialPresenceState, { type: 'set_self', user: 'india' });
state = reducePresence(state, {
type: 'notes_typing',
user: 'comrade',
active: true,
ts: 100,
});
expect(state.notesTyping?.user).toBe('comrade');
state = reducePresence(state, {
type: 'notes_typing',
user: 'india',
active: true,
ts: 101,
});
expect(state.notesTyping?.user).toBe('comrade');
state = reducePresence(state, {
type: 'notes_typing',
user: 'comrade',
active: false,
ts: 102,
});
expect(state.notesTyping).toBeNull();
});
it('clears notes_typing via clear_notes_typing', () => {
let state = reducePresence(initialPresenceState, {
type: 'notes_typing',
user: 'comrade',
active: true,
ts: 1,
});
state = reducePresence(state, { type: 'clear_notes_typing' });
expect(state.notesTyping).toBeNull();
});
it('replaces stale notes_typing when another user types', () => {
let state = reducePresence(initialPresenceState, {
type: 'notes_typing',
user: 'alpha',
active: true,
ts: 1,
});
state = reducePresence(state, {
type: 'notes_typing',
user: 'bravo',
active: true,
ts: 2,
});
expect(state.notesTyping?.user).toBe('bravo');
});
it('normalizes page paths for comradesOnPage', () => {
let state = reducePresence(initialPresenceState, {
type: 'presence_update',
user: 'comrade',
page: 'crucible',
online: true,
ts: 1,
});
expect(comradesOnPage(state, '/crucible')).toHaveLength(1);
expect(comradesOnPage(state, 'crucible')).toHaveLength(1);
expect(comradesOnPage(state, '/emberwake')).toHaveLength(0);
});
it('snapshot skips offline comrades and self', () => {
const state = reducePresence(
{ ...initialPresenceState, selfUser: 'india' },
{
type: 'presence_snapshot',
comrades: [
{ user: 'india', page: '/dashboard', online: true, ts: 1 },
{ user: 'ghost', page: '/forge', online: false, ts: 2 },
{ user: 'comrade', page: '/crucible', online: true, ts: 3 },
],
},
);
expect(onlineComrades(state).map((c) => c.user)).toEqual(['comrade']);
});
});

View File

@@ -0,0 +1,92 @@
export interface ComradePresence {
user: string;
page: string;
online: boolean;
ts: number;
}
export interface NotesTyping {
user: string;
active: boolean;
ts: number;
}
export interface PresenceState {
comrades: Record<string, ComradePresence>;
notesTyping: NotesTyping | null;
selfUser: string | null;
}
export const initialPresenceState: PresenceState = {
comrades: {},
notesTyping: null,
selfUser: null,
};
export type PresenceAction =
| { type: 'set_self'; user: string | null }
| { type: 'presence_snapshot'; comrades: ComradePresence[] }
| { type: 'presence_update'; user: string; page: string; online: boolean; ts: number }
| { type: 'notes_typing'; user: string; active: boolean; ts: number }
| { type: 'clear_notes_typing' };
export function reducePresence(state: PresenceState, action: PresenceAction): PresenceState {
switch (action.type) {
case 'set_self':
return { ...state, selfUser: action.user };
case 'presence_snapshot': {
const comrades: Record<string, ComradePresence> = {};
for (const c of action.comrades) {
if (!c.user || !c.online) continue;
if (state.selfUser && c.user === state.selfUser) continue;
comrades[c.user] = { ...c, online: true };
}
return { ...state, comrades };
}
case 'presence_update': {
const next = { ...state.comrades };
if (!action.online || (state.selfUser && action.user === state.selfUser)) {
delete next[action.user];
return { ...state, comrades: next };
}
next[action.user] = {
user: action.user,
page: action.page || '/dashboard',
online: true,
ts: action.ts,
};
return { ...state, comrades: next };
}
case 'notes_typing': {
if (state.selfUser && action.user === state.selfUser) {
return state;
}
if (!action.active) {
if (state.notesTyping?.user === action.user) {
return { ...state, notesTyping: null };
}
return state;
}
return {
...state,
notesTyping: { user: action.user, active: true, ts: action.ts },
};
}
case 'clear_notes_typing':
return { ...state, notesTyping: null };
default:
return state;
}
}
export function onlineComrades(state: PresenceState): ComradePresence[] {
return Object.values(state.comrades).filter((c) => c.online);
}
export function comradesOnPage(state: PresenceState, page: string): ComradePresence[] {
const normalized = page.startsWith('/') ? page : `/${page}`;
return onlineComrades(state).filter((c) => {
const p = c.page.startsWith('/') ? c.page : `/${c.page}`;
return p === normalized;
});
}