diff --git a/extension/sidepanel.css b/extension/sidepanel.css index 52d4d644..54b652e8 100644 --- a/extension/sidepanel.css +++ b/extension/sidepanel.css @@ -47,6 +47,68 @@ --radius-full: 9999px; } +/* ─── Connection Banner ─────────────────────────────────────────── */ + +.conn-banner { + padding: 6px 10px; + font-size: 10px; + font-family: var(--font-mono); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.conn-banner.reconnecting { + background: rgba(245, 158, 11, 0.1); + border-bottom: 1px solid rgba(245, 158, 11, 0.2); + color: var(--amber-400); +} + +.conn-banner.dead { + background: rgba(239, 68, 68, 0.1); + border-bottom: 1px solid rgba(239, 68, 68, 0.2); + color: var(--error); +} + +.conn-banner.reconnected { + background: rgba(34, 197, 94, 0.1); + border-bottom: 1px solid rgba(34, 197, 94, 0.2); + color: var(--success); + animation: fadeOut 3s ease forwards; + animation-delay: 2s; +} + +@keyframes fadeOut { + to { opacity: 0; height: 0; padding: 0; overflow: hidden; } +} + +.conn-banner-text { + flex: 1; +} + +.conn-btn { + font-size: 9px; + font-family: var(--font-mono); + padding: 2px 8px; + border-radius: var(--radius-sm); + cursor: pointer; + border: 1px solid var(--border); + background: var(--bg-surface); + color: var(--text-label); + transition: all 150ms; +} + +.conn-btn:hover { + background: var(--bg-hover); + color: var(--text-heading); +} + +.conn-copy { + color: var(--text-meta); + font-style: italic; +} + body { background: var(--bg-base); color: var(--text-body); diff --git a/extension/sidepanel.html b/extension/sidepanel.html index 68b01b54..fff42a18 100644 --- a/extension/sidepanel.html +++ b/extension/sidepanel.html @@ -5,6 +5,15 @@ + + +
diff --git a/extension/sidepanel.js b/extension/sidepanel.js index a037f618..ed7fd1e9 100644 --- a/extension/sidepanel.js +++ b/extension/sidepanel.js @@ -16,6 +16,10 @@ let serverUrl = null; let serverToken = null; let chatLineCount = 0; let chatPollInterval = null; +let connState = 'disconnected'; // disconnected | connected | reconnecting | dead +let reconnectAttempts = 0; +let reconnectTimer = null; +const MAX_RECONNECT_ATTEMPTS = 30; // 30 * 2s = 60s before showing "dead" // Auth headers for sidebar endpoints function authHeaders() { @@ -24,6 +28,58 @@ function authHeaders() { return h; } +// ─── Connection State Machine ───────────────────────────────────── + +function setConnState(state) { + const prev = connState; + connState = state; + const banner = document.getElementById('conn-banner'); + const bannerText = document.getElementById('conn-banner-text'); + const bannerActions = document.getElementById('conn-banner-actions'); + + if (state === 'connected') { + if (prev === 'reconnecting' || prev === 'dead') { + // Show "reconnected" toast that fades + banner.style.display = ''; + banner.className = 'conn-banner reconnected'; + bannerText.textContent = 'Reconnected'; + bannerActions.style.display = 'none'; + setTimeout(() => { banner.style.display = 'none'; }, 5000); + } else { + banner.style.display = 'none'; + } + reconnectAttempts = 0; + if (reconnectTimer) { clearInterval(reconnectTimer); reconnectTimer = null; } + } else if (state === 'reconnecting') { + banner.style.display = ''; + banner.className = 'conn-banner reconnecting'; + bannerText.textContent = `Reconnecting... (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`; + bannerActions.style.display = 'none'; + } else if (state === 'dead') { + banner.style.display = ''; + banner.className = 'conn-banner dead'; + bannerText.textContent = 'Server offline'; + bannerActions.style.display = ''; + if (reconnectTimer) { clearInterval(reconnectTimer); reconnectTimer = null; } + } else { + banner.style.display = 'none'; + } +} + +function startReconnect() { + if (reconnectTimer) return; + setConnState('reconnecting'); + reconnectTimer = setInterval(() => { + reconnectAttempts++; + if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) { + setConnState('dead'); + return; + } + setConnState('reconnecting'); + tryConnect(); + }, 2000); +} + // ─── Chat ─────────────────────────────────────────────────────── const chatMessages = document.getElementById('chat-messages'); @@ -451,21 +507,25 @@ async function fetchRefs() { // ─── Server Discovery ─────────────────────────────────────────── function updateConnection(url, token) { + const wasConnected = !!serverUrl; serverUrl = url; serverToken = token || null; if (url) { document.getElementById('footer-dot').className = 'dot connected'; const port = new URL(url).port; document.getElementById('footer-port').textContent = `:${port}`; + setConnState('connected'); connectSSE(); - // Start chat polling if (chatPollInterval) clearInterval(chatPollInterval); chatPollInterval = setInterval(pollChat, 1000); - pollChat(); // immediate first poll + pollChat(); } else { document.getElementById('footer-dot').className = 'dot'; document.getElementById('footer-port').textContent = ''; if (chatPollInterval) { clearInterval(chatPollInterval); chatPollInterval = null; } + if (wasConnected) { + startReconnect(); + } } } @@ -498,6 +558,21 @@ portInput.addEventListener('keydown', (e) => { if (e.key === 'Escape') { portInput.style.display = 'none'; portLabel.style.display = ''; } }); +// ─── Reconnect / Copy Buttons ──────────────────────────────────── + +document.getElementById('conn-reconnect').addEventListener('click', () => { + reconnectAttempts = 0; + startReconnect(); +}); + +document.getElementById('conn-copy').addEventListener('click', () => { + navigator.clipboard.writeText('/connect-chrome').then(() => { + const btn = document.getElementById('conn-copy'); + btn.textContent = 'copied!'; + setTimeout(() => { btn.textContent = '/connect-chrome'; }, 2000); + }); +}); + // Try to connect immediately, retry every 2s until connected function tryConnect() { chrome.runtime.sendMessage({ type: 'getPort' }, (resp) => {