Files
intercept/static/js/core/sse-manager.js
Smittix e687862043 feat: UI/UX overhaul — CSS cleanup, accessibility, error handling, inline style extraction
Phase 0 — CSS-only fixes:
- Fix --font-mono to use real monospace stack (JetBrains Mono, Fira Code, etc.)
- Replace hardcoded hex colors with CSS variables across 16+ files
- Merge global-nav.css (507 lines) into layout.css, delete original
- Reduce !important in responsive.css from 71 to 8 via .app-shell specificity
- Standardize breakpoints to 480/768/1024/1280px

Phase 1 — Loading states & SSE connection feedback:
- Add centralized SSEManager (sse-manager.js) with exponential backoff
- Add SSE status indicator dot in nav bar
- Add withLoadingButton() + .btn-loading CSS spinner
- Add mode section crossfade transitions

Phase 2 — Accessibility:
- Add aria-labels to icon-only buttons across mode partials
- Add for/id associations to 42 form labels in 5 mode partials
- Add aria-live on toast stack, enableListKeyNav() utility

Phase 3 — Destructive action guards & list overflow:
- Add confirmAction() styled modal, replace all 25 native confirm() calls
- Add toast cap at 5 simultaneous toasts
- Add list overflow indicator CSS

Phase 4 — Inline style extraction:
- Refactor switchMode() in app.js and index.html to use classList.toggle()
- Add CSS toggle rules for all switchMode-controlled elements
- Remove inline style="display:none" from 7+ HTML elements
- Add utility classes (.hidden, .d-flex, .d-grid, etc.)

Phase 5 — Mobile UX polish:
- pre/code overflow handling already in place
- Touch target sizing via --touch-min variable

Phase 6 — Error handling consistency:
- Add reportActionableError() to user-facing catch blocks in 5 mode JS files
- 28 error toast additions alongside existing console.error calls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:04:36 +00:00

246 lines
7.5 KiB
JavaScript

/**
* SSEManager - Centralized Server-Sent Events connection manager
* Handles connection lifecycle, reconnection with exponential backoff,
* visibility-based pause/resume, and state change notifications.
*/
const SSEManager = (function() {
'use strict';
const STATES = {
CONNECTING: 'connecting',
OPEN: 'open',
RECONNECTING: 'reconnecting',
CLOSED: 'closed',
ERROR: 'error',
};
const BACKOFF_INITIAL = 1000;
const BACKOFF_MAX = 30000;
const BACKOFF_MULTIPLIER = 2;
/** @type {Map<string, ConnectionEntry>} */
const connections = new Map();
/**
* @typedef {Object} ConnectionEntry
* @property {string} key
* @property {string} url
* @property {EventSource|null} source
* @property {string} state
* @property {number} backoff
* @property {number|null} retryTimer
* @property {boolean} intentionallyClosed
* @property {Function|null} onMessage
* @property {Function|null} onStateChange
*/
function connect(key, url, options) {
const opts = options || {};
// Disconnect existing connection for this key
if (connections.has(key)) {
disconnect(key);
}
const entry = {
key: key,
url: url,
source: null,
state: STATES.CLOSED,
backoff: BACKOFF_INITIAL,
retryTimer: null,
intentionallyClosed: false,
onMessage: typeof opts.onMessage === 'function' ? opts.onMessage : null,
onStateChange: typeof opts.onStateChange === 'function' ? opts.onStateChange : null,
};
connections.set(key, entry);
openConnection(entry);
return entry;
}
function openConnection(entry) {
if (entry.intentionallyClosed) return;
setState(entry, entry.state === STATES.CLOSED ? STATES.CONNECTING : STATES.RECONNECTING);
try {
const source = new EventSource(entry.url);
entry.source = source;
source.onopen = function() {
entry.backoff = BACKOFF_INITIAL;
setState(entry, STATES.OPEN);
};
source.onmessage = function(event) {
if (entry.onMessage) {
try {
entry.onMessage(event);
} catch (err) {
console.debug('[SSEManager] onMessage error for ' + entry.key + ':', err);
}
}
};
source.onerror = function() {
// EventSource fires error on close and connection loss
if (entry.intentionallyClosed) return;
closeSource(entry);
setState(entry, STATES.ERROR);
scheduleReconnect(entry);
};
} catch (err) {
setState(entry, STATES.ERROR);
scheduleReconnect(entry);
}
}
function closeSource(entry) {
if (entry.source) {
entry.source.onopen = null;
entry.source.onmessage = null;
entry.source.onerror = null;
try { entry.source.close(); } catch (e) { /* ignore */ }
entry.source = null;
}
}
function scheduleReconnect(entry) {
if (entry.intentionallyClosed) return;
if (entry.retryTimer) return;
// Pause reconnection when tab is hidden
if (document.hidden) {
setState(entry, STATES.RECONNECTING);
return;
}
const delay = entry.backoff;
entry.backoff = Math.min(entry.backoff * BACKOFF_MULTIPLIER, BACKOFF_MAX);
setState(entry, STATES.RECONNECTING);
entry.retryTimer = window.setTimeout(function() {
entry.retryTimer = null;
if (!entry.intentionallyClosed) {
openConnection(entry);
}
}, delay);
}
function disconnect(key) {
const entry = connections.get(key);
if (!entry) return;
entry.intentionallyClosed = true;
if (entry.retryTimer) {
clearTimeout(entry.retryTimer);
entry.retryTimer = null;
}
closeSource(entry);
setState(entry, STATES.CLOSED);
connections.delete(key);
}
function disconnectAll() {
for (const key of Array.from(connections.keys())) {
disconnect(key);
}
}
function getState(key) {
const entry = connections.get(key);
return entry ? entry.state : STATES.CLOSED;
}
function getActiveKeys() {
const keys = [];
connections.forEach(function(entry, key) {
if (entry.state === STATES.OPEN) {
keys.push(key);
}
});
return keys;
}
function setState(entry, newState) {
if (entry.state === newState) return;
const oldState = entry.state;
entry.state = newState;
if (entry.onStateChange) {
try {
entry.onStateChange(newState, oldState, entry.key);
} catch (err) {
console.debug('[SSEManager] onStateChange error:', err);
}
}
// Update global indicator
updateGlobalIndicator();
}
// --- Global SSE Status Indicator ---
function updateGlobalIndicator() {
const dot = document.getElementById('sseStatusDot');
if (!dot) return;
let hasOpen = false;
let hasReconnecting = false;
let hasError = false;
connections.forEach(function(entry) {
if (entry.state === STATES.OPEN) hasOpen = true;
else if (entry.state === STATES.RECONNECTING || entry.state === STATES.CONNECTING) hasReconnecting = true;
else if (entry.state === STATES.ERROR) hasError = true;
});
// Remove all state classes
dot.classList.remove('online', 'warning', 'error', 'inactive');
if (connections.size === 0) {
dot.classList.add('inactive');
dot.setAttribute('data-tooltip', 'No active streams');
} else if (hasError && !hasOpen) {
dot.classList.add('error');
dot.setAttribute('data-tooltip', 'Stream connection error');
} else if (hasReconnecting) {
dot.classList.add('warning');
dot.setAttribute('data-tooltip', 'Reconnecting...');
} else if (hasOpen) {
dot.classList.add('online');
dot.setAttribute('data-tooltip', 'Streams connected');
} else {
dot.classList.add('inactive');
dot.setAttribute('data-tooltip', 'Streams idle');
}
}
// --- Visibility API: pause/resume reconnection ---
document.addEventListener('visibilitychange', function() {
if (document.hidden) return;
// Tab became visible — reconnect any entries that were waiting
connections.forEach(function(entry) {
if (!entry.intentionallyClosed && !entry.source && !entry.retryTimer) {
openConnection(entry);
}
});
});
return {
STATES: STATES,
connect: connect,
disconnect: disconnect,
disconnectAll: disconnectAll,
getState: getState,
getActiveKeys: getActiveKeys,
};
})();