feat: self-healing sidebar — reconnect banner, state machine, copy button

Sidebar UI now handles disconnection gracefully:
- Connection state machine: connected → reconnecting → dead
- Amber pulsing banner during reconnect (2s retry, 30 attempts)
- Red "Server offline" banner with Reconnect + Copy /connect-chrome buttons
- Green "Reconnected" toast that fades after 3s on successful reconnect
- Copy button lets user paste /connect-chrome into any Claude Code session

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-21 19:32:41 -07:00
co-authored by Claude Opus 4.6
parent 7e8684f923
commit 530a4ef22c
3 changed files with 148 additions and 2 deletions
+77 -2
View File
@@ -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) => {