From 2b48394f63c03ee9e07ee19aea97b943f49769a9 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 21 Mar 2026 15:09:49 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20sidebar=20chat=20with=20Claude=20Code?= =?UTF-8?q?=20=E2=80=94=20icon=20opens=20side=20panel=20directly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace popup flyout with direct side panel open on icon click. Primary UI is now a chat interface that sends messages to Claude Code via file queue. Activity/Refs tabs moved behind a debug toggle in the footer. Command bar with history, auto-poll for responses, amber design system. Co-Authored-By: Claude Opus 4.6 (1M context) --- extension/background.js | 69 +++++++- extension/manifest.json | 1 - extension/sidepanel.css | 362 ++++++++++++++++++++++++++++++--------- extension/sidepanel.html | 61 +++---- extension/sidepanel.js | 240 ++++++++++++++++++++------ 5 files changed, 563 insertions(+), 170 deletions(-) diff --git a/extension/background.js b/extension/background.js index 6b5b08b7..e17834db 100644 --- a/extension/background.js +++ b/extension/background.js @@ -3,10 +3,13 @@ * * Polls /health every 10s to detect browse server. * Fetches /refs on snapshot completion, relays to content script. - * Updates badge: green (connected), gray (disconnected). + * Proxies commands from sidebar → browse server. + * Updates badge: amber (connected), gray (disconnected). */ +const DEFAULT_PORT = 34567; // Well-known port used by `$B connect` let serverPort = null; +let authToken = null; let isConnected = false; let healthInterval = null; @@ -14,7 +17,7 @@ let healthInterval = null; async function loadPort() { const data = await chrome.storage.local.get('port'); - serverPort = data.port || null; + serverPort = data.port || DEFAULT_PORT; return serverPort; } @@ -41,6 +44,8 @@ async function checkHealth() { if (!resp.ok) { setDisconnected(); return; } const data = await resp.json(); if (data.status === 'healthy') { + // Capture auth token from health response + if (data.token) authToken = data.token; setConnected(data); } else { setDisconnected(); @@ -53,7 +58,7 @@ async function checkHealth() { function setConnected(healthData) { const wasDisconnected = !isConnected; isConnected = true; - chrome.action.setBadgeBackgroundColor({ color: '#4ade80' }); + chrome.action.setBadgeBackgroundColor({ color: '#F59E0B' }); chrome.action.setBadgeText({ text: ' ' }); // Broadcast health to popup and side panel @@ -68,6 +73,7 @@ function setConnected(healthData) { function setDisconnected() { const wasConnected = isConnected; isConnected = false; + authToken = null; chrome.action.setBadgeText({ text: '' }); chrome.runtime.sendMessage({ type: 'health', data: null }).catch(() => {}); @@ -89,6 +95,31 @@ async function notifyContentScripts(type) { } catch {} } +// ─── Command Proxy ───────────────────────────────────────────── + +async function executeCommand(command, args) { + const base = getBaseUrl(); + if (!base || !authToken) { + return { error: 'Not connected to browse server' }; + } + + try { + const resp = await fetch(`${base}/command`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, + }, + body: JSON.stringify({ command, args }), + signal: AbortSignal.timeout(30000), + }); + const data = await resp.json(); + return data; + } catch (err) { + return { error: err.message || 'Command failed' }; + } +} + // ─── Refs Relay ───────────────────────────────────────────────── async function fetchAndRelayRefs() { @@ -135,11 +166,41 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { fetchAndRelayRefs().then(() => sendResponse({ ok: true })); return true; } + + // Sidebar → browse server command proxy + if (msg.type === 'command') { + executeCommand(msg.command, msg.args).then(result => sendResponse(result)); + return true; + } + + // Sidebar → Claude Code (file-based message queue) + if (msg.type === 'sidebar-command') { + const base = getBaseUrl(); + if (!base || !authToken) { + sendResponse({ error: 'Not connected' }); + return true; + } + fetch(`${base}/sidebar-command`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, + }, + body: JSON.stringify({ message: msg.message }), + }) + .then(r => r.json()) + .then(data => sendResponse(data)) + .catch(err => sendResponse({ error: err.message })); + return true; + } }); // ─── Side Panel ───────────────────────────────────────────────── -chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: false }).catch(() => {}); +// Click extension icon → open side panel directly (no popup) +if (chrome.sidePanel && chrome.sidePanel.setPanelBehavior) { + chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {}); +} // ─── Startup ──────────────────────────────────────────────────── diff --git a/extension/manifest.json b/extension/manifest.json index 3a28d5ed..ea710e14 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -6,7 +6,6 @@ "permissions": ["sidePanel", "storage", "activeTab"], "host_permissions": ["http://127.0.0.1:*/"], "action": { - "default_popup": "popup.html", "default_icon": { "16": "icons/icon-16.png", "48": "icons/icon-48.png", diff --git a/extension/sidepanel.css b/extension/sidepanel.css index 87ecf7b7..c5aea6ac 100644 --- a/extension/sidepanel.css +++ b/extension/sidepanel.css @@ -1,32 +1,54 @@ -/* gstack browse — Side Panel dark theme */ -/* Design tokens from cookie picker, extended */ +/* gstack browse — Side Panel + * Design system: DESIGN.md (Industrial/Utilitarian, amber accent, zinc neutrals) + */ * { margin: 0; padding: 0; box-sizing: border-box; } :root { - --bg-body: #0a0a0a; - --bg-header: #0f0f0f; - --bg-surface: #1a1a1a; - --bg-hover: #151515; - --border: #222; - --border-inactive: #333; - --border-hover: #555; - --text-heading: #fff; + /* Brand — amber accent, rare and meaningful */ + --amber-400: #FBBF24; + --amber-500: #F59E0B; + --amber-600: #D97706; + + /* Neutrals — cool zinc */ + --zinc-50: #FAFAFA; + --zinc-400: #A1A1AA; + --zinc-600: #52525B; + --zinc-800: #27272A; + + /* Surfaces */ + --bg-base: #0C0C0C; + --bg-surface: #141414; + --bg-hover: #1a1a1a; + --border: #262626; + --border-subtle: #1f1f1f; + + /* Text hierarchy */ + --text-heading: #FAFAFA; --text-body: #e0e0e0; - --text-label: #888; - --text-meta: #666; - --text-disabled: #555; - --green: #4ade80; - --red: #f87171; - --blue: #60a5fa; - --purple: #a78bfa; - --amber: #fbbf24; + --text-label: #A1A1AA; + --text-meta: #52525B; + --text-disabled: #3f3f46; + + /* Semantic */ + --success: #22C55E; + --warning: #F59E0B; + --error: #EF4444; + --info: #3B82F6; + + /* Typography */ --font-system: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; - --font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + --font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + + /* Radius */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-full: 9999px; } body { - background: var(--bg-body); + background: var(--bg-base); color: var(--text-body); font-family: var(--font-system); font-size: 13px; @@ -36,55 +58,145 @@ body { overflow: hidden; } -/* ─── Header ──────────────────────────────────────────── */ -header { - height: 40px; - background: var(--bg-header); - border-bottom: 1px solid var(--border); - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 12px; - flex-shrink: 0; +/* Grain texture overlay */ +body::after { + content: ''; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + pointer-events: none; + z-index: 9999; + opacity: 0.03; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); } -.header-left { display: flex; align-items: center; gap: 8px; } -.monogram { - width: 22px; - height: 22px; - background: var(--green); - color: #000; - font-weight: 700; - font-size: 13px; - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; -} -.title { color: var(--text-heading); font-weight: 600; font-size: 14px; letter-spacing: -0.3px; } -.header-right { display: flex; align-items: center; gap: 6px; } -.header-port { color: var(--text-meta); font-family: var(--font-mono); font-size: 11px; } /* ─── Status Dot ──────────────────────────────────────── */ .dot { width: 8px; height: 8px; - border-radius: 50%; + border-radius: var(--radius-full); background: var(--text-disabled); flex-shrink: 0; + transition: background 150ms; } -.dot.connected { background: var(--green); } +.dot.connected { background: var(--success); } .dot.reconnecting { - background: var(--amber); - animation: pulse 1.5s ease-in-out infinite; + background: var(--amber-500); + animation: pulse 2s ease-in-out infinite; } @keyframes pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } } +/* ─── Chat Messages ───────────────────────────────────── */ +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} +.chat-welcome { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; + color: var(--text-label); + gap: 8px; + padding: 24px; +} +.chat-welcome-icon { + width: 40px; + height: 40px; + background: var(--amber-500); + color: #000; + font-weight: 800; + font-size: 22px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 8px; +} +.chat-welcome .muted { color: var(--text-meta); font-size: 12px; } + +.chat-bubble { + max-width: 90%; + padding: 8px 12px; + border-radius: var(--radius-lg); + font-size: 13px; + line-height: 1.5; + word-break: break-word; + animation: slideIn 150ms ease-out; +} +.chat-bubble.user { + align-self: flex-end; + background: var(--amber-500); + color: #000; + border-bottom-right-radius: var(--radius-sm); +} +.chat-bubble.assistant { + align-self: flex-start; + background: var(--bg-surface); + color: var(--text-body); + border: 1px solid var(--border); + border-bottom-left-radius: var(--radius-sm); +} +.chat-bubble.assistant pre { + background: var(--bg-base); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 6px 8px; + margin: 6px 0; + overflow-x: auto; + font-family: var(--font-mono); + font-size: 12px; + white-space: pre-wrap; +} +.chat-bubble .chat-time { + font-size: 10px; + opacity: 0.5; + margin-top: 4px; + display: block; +} + +/* ─── Debug Toggle ────────────────────────────────────── */ +.debug-toggle { + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-meta); + font-family: var(--font-mono); + font-size: 10px; + padding: 2px 6px; + cursor: pointer; + transition: all 150ms; +} +.debug-toggle:hover { + color: var(--text-label); + border-color: var(--zinc-600); +} +.debug-toggle.active { + color: var(--amber-400); + border-color: var(--amber-500); +} +.debug-tabs { + border-top: 1px solid var(--border); +} +.close-debug { + width: 36px; + flex: none !important; + font-size: 16px; + color: var(--text-meta) !important; +} +.close-debug:hover { color: var(--text-label) !important; } + /* ─── Tab Bar ─────────────────────────────────────────── */ .tabs { height: 36px; - background: var(--bg-header); + background: var(--bg-surface); border-bottom: 1px solid var(--border); display: flex; flex-shrink: 0; @@ -98,12 +210,12 @@ header { font-weight: 500; cursor: pointer; border-bottom: 2px solid transparent; - transition: all 0.15s; + transition: all 150ms; } -.tab:hover:not(.disabled) { color: #ccc; } +.tab:hover:not(.disabled) { color: var(--zinc-50); } .tab.active { color: var(--text-heading); - border-bottom-color: var(--green); + border-bottom-color: var(--amber-500); } .tab.disabled { color: var(--text-disabled); @@ -125,10 +237,10 @@ header { .activity-entry { padding: 8px 12px; border-left: 3px solid var(--border); - border-bottom: 1px solid var(--border); + border-bottom: 1px solid var(--border-subtle); cursor: pointer; - transition: background 0.15s; - animation: slideIn 0.15s ease; + transition: background 150ms; + animation: slideIn 150ms ease-out; } .activity-entry:hover { background: var(--bg-hover); } @@ -142,17 +254,17 @@ header { } /* Left border colors by type */ -.activity-entry.nav { border-left-color: var(--blue); } -.activity-entry.interaction { border-left-color: var(--green); } -.activity-entry.observe { border-left-color: var(--purple); } -.activity-entry.error { border-left-color: var(--red); } +.activity-entry.nav { border-left-color: var(--info); } +.activity-entry.interaction { border-left-color: var(--success); } +.activity-entry.observe { border-left-color: var(--amber-400); } +.activity-entry.error { border-left-color: var(--error); } .activity-entry.pending { - border-left-color: var(--amber); - animation: slideIn 0.15s ease, borderPulse 1.5s ease-in-out infinite; + border-left-color: var(--amber-500); + animation: slideIn 150ms ease-out, borderPulse 2s ease-in-out infinite; } @keyframes borderPulse { - 0%, 100% { border-left-color: rgba(251, 191, 36, 0.4); } - 50% { border-left-color: rgba(251, 191, 36, 1); } + 0%, 100% { border-left-color: rgba(245, 158, 11, 0.3); } + 50% { border-left-color: rgba(245, 158, 11, 1); } } .entry-header { @@ -188,8 +300,8 @@ header { align-items: center; gap: 4px; } -.entry-status .ok { color: var(--green); } -.entry-status .err { color: var(--red); } +.entry-status .ok { color: var(--success); } +.entry-status .err { color: var(--error); } .entry-status .duration { color: var(--text-meta); } /* Expanded state */ @@ -202,7 +314,7 @@ header { .activity-entry.expanded .entry-detail { display: block; } .activity-entry.expanded .entry-args { white-space: normal; } .entry-result { - color: #aaa; + color: var(--zinc-400); font-family: var(--font-mono); font-size: 12px; white-space: pre-wrap; @@ -216,11 +328,11 @@ header { align-items: center; gap: 8px; padding: 0 12px; - border-bottom: 1px solid var(--border); + border-bottom: 1px solid var(--border-subtle); font-size: 12px; } .ref-id { - color: var(--green); + color: var(--amber-400); font-family: var(--font-mono); font-weight: 600; min-width: 32px; @@ -271,29 +383,97 @@ header { .empty-state code { background: var(--bg-surface); padding: 2px 6px; - border-radius: 3px; + border-radius: var(--radius-sm); font-family: var(--font-mono); font-size: 12px; } /* ─── Gap Banner ──────────────────────────────────────── */ .gap-banner { - background: rgba(251, 191, 36, 0.1); - border-bottom: 1px solid var(--amber); - color: var(--amber); + background: rgba(245, 158, 11, 0.08); + border-bottom: 1px solid var(--amber-500); + color: var(--amber-400); font-size: 11px; padding: 6px 12px; - animation: bannerSlide 0.2s ease; + animation: bannerSlide 250ms ease-out; } @keyframes bannerSlide { from { transform: translateY(-100%); } to { transform: translateY(0); } } +/* ─── Command Bar ─────────────────────────────────────── */ +.command-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + background: var(--bg-surface); + border-top: 1px solid var(--border); + flex-shrink: 0; +} +.command-prompt { + color: var(--amber-500); + font-family: var(--font-mono); + font-size: 14px; + font-weight: 700; + flex-shrink: 0; + user-select: none; +} +.command-input { + flex: 1; + background: var(--bg-base); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 8px 10px; + color: var(--text-heading); + font-family: var(--font-system); + font-size: 13px; + outline: none; + transition: border-color 150ms; +} +.command-input:focus { border-color: var(--amber-500); } +.command-input::placeholder { color: var(--text-disabled); font-size: 12px; } +.command-input.sent { + border-color: var(--success); + transition: border-color 150ms; +} +.command-input.error { + border-color: var(--error); + animation: shake 300ms ease; +} +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-4px); } + 75% { transform: translateX(4px); } +} +.send-btn { + width: 32px; + height: 32px; + background: var(--amber-500); + border: none; + border-radius: var(--radius-md); + color: #000; + font-size: 16px; + font-weight: 700; + cursor: pointer; + flex-shrink: 0; + transition: all 150ms; + display: flex; + align-items: center; + justify-content: center; +} +.send-btn:hover { background: var(--amber-400); } +.send-btn:active { transform: scale(0.93); } +.send-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + /* ─── Footer ──────────────────────────────────────────── */ footer { height: 32px; - background: var(--bg-header); + background: var(--bg-surface); border-top: 1px solid var(--border); display: flex; align-items: center; @@ -307,11 +487,37 @@ footer { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - max-width: 60%; + max-width: 50%; } +.footer-right { + display: flex; + align-items: center; + gap: 6px; +} +.footer-port { + color: var(--text-meta); + font-family: var(--font-mono); + font-size: 11px; + cursor: pointer; + transition: color 150ms; +} +.footer-port:hover { color: var(--text-label); } +.port-input { + width: 56px; + padding: 2px 6px; + background: var(--bg-base); + border: 1px solid var(--zinc-600); + border-radius: var(--radius-sm); + color: var(--text-heading); + font-family: var(--font-mono); + font-size: 11px; + outline: none; + transition: border-color 150ms; +} +.port-input:focus { border-color: var(--amber-500); } /* ─── Accessibility ───────────────────────────────────── */ :focus-visible { - outline: 2px solid var(--green); + outline: 2px solid var(--amber-500); outline-offset: 1px; } diff --git a/extension/sidepanel.html b/extension/sidepanel.html index ca519ea3..16cb6483 100644 --- a/extension/sidepanel.html +++ b/extension/sidepanel.html @@ -5,27 +5,19 @@ - -
-
- G - gstack + +
+
+
+
G
+

Send a message to Claude Code.

+

Your agent will see it and act on it.

+
-
- - -
-
+ - - - - -
+ +

Waiting for commands...

Run a browse command to see activity here.

@@ -33,7 +25,7 @@
- +

No refs yet

@@ -43,20 +35,29 @@
- -
-
-

Full session view requires Conductor.

-

Activity tab shows browse commands.

-
-
+ +
+ + +
- +
- - + +
+ + + diff --git a/extension/sidepanel.js b/extension/sidepanel.js index 007e22c4..2a56cbf2 100644 --- a/extension/sidepanel.js +++ b/extension/sidepanel.js @@ -1,9 +1,9 @@ /** * gstack browse — Side Panel * - * Connects to browse server SSE stream for live activity. - * Fetches /refs for the Refs tab. - * Cursor-based replay ensures no missed events on reconnect. + * Chat tab: two-way messaging with Claude Code via file queue. + * Debug tabs: activity feed (SSE) + refs (REST). + * Polls /sidebar-chat for new messages every 1s. */ const NAV_COMMANDS = new Set(['goto', 'back', 'forward', 'reload']); @@ -13,23 +13,146 @@ const OBSERVE_COMMANDS = new Set(['snapshot', 'screenshot', 'diff', 'console', ' let lastId = 0; let eventSource = null; let serverUrl = null; -let pendingEntries = new Map(); // id → entry element (for command_start without command_end) +let chatLineCount = 0; +let chatPollInterval = null; -// ─── Tab Switching ───────────────────────────────────────────── +// ─── Chat ─────────────────────────────────────────────────────── -document.querySelectorAll('.tab:not(.disabled)').forEach(tab => { +const chatMessages = document.getElementById('chat-messages'); +const commandInput = document.getElementById('command-input'); +const sendBtn = document.getElementById('send-btn'); +const commandHistory = []; +let historyIndex = -1; + +function formatChatTime(ts) { + const d = new Date(ts); + return d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' }); +} + +function addChatBubble(entry) { + // Remove welcome message on first real message + const welcome = chatMessages.querySelector('.chat-welcome'); + if (welcome) welcome.remove(); + + const bubble = document.createElement('div'); + bubble.className = `chat-bubble ${entry.role}`; + + let content = escapeHtml(entry.message); + // Simple markdown-ish: wrap ```...``` in
+  content = content.replace(/```([\s\S]*?)```/g, '
$1
'); + // Bold **text** + content = content.replace(/\*\*(.*?)\*\*/g, '$1'); + // Line breaks + content = content.replace(/\n/g, '
'); + + bubble.innerHTML = `${content}${formatChatTime(entry.ts)}`; + chatMessages.appendChild(bubble); + bubble.scrollIntoView({ behavior: 'smooth', block: 'end' }); +} + +async function sendMessage() { + const msg = commandInput.value.trim(); + if (!msg) return; + + commandHistory.push(msg); + historyIndex = commandHistory.length; + commandInput.value = ''; + commandInput.disabled = true; + sendBtn.disabled = true; + + const result = await new Promise((resolve) => { + chrome.runtime.sendMessage({ type: 'sidebar-command', message: msg }, resolve); + }); + + commandInput.disabled = false; + sendBtn.disabled = false; + commandInput.focus(); + + if (result?.ok) { + // Immediately poll to show the user's own message + pollChat(); + } else { + commandInput.classList.add('error'); + commandInput.placeholder = result?.error || 'Failed to send'; + setTimeout(() => { + commandInput.classList.remove('error'); + commandInput.placeholder = 'Message Claude Code...'; + }, 2000); + } +} + +commandInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); sendMessage(); } + if (e.key === 'ArrowUp') { + e.preventDefault(); + if (historyIndex > 0) { historyIndex--; commandInput.value = commandHistory[historyIndex]; } + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (historyIndex < commandHistory.length - 1) { historyIndex++; commandInput.value = commandHistory[historyIndex]; } + else { historyIndex = commandHistory.length; commandInput.value = ''; } + } +}); + +sendBtn.addEventListener('click', sendMessage); + +// Poll for new chat messages +async function pollChat() { + if (!serverUrl) return; + try { + const resp = await fetch(`${serverUrl}/sidebar-chat?after=${chatLineCount}`, { + signal: AbortSignal.timeout(3000), + }); + if (!resp.ok) return; + const data = await resp.json(); + if (data.entries && data.entries.length > 0) { + for (const entry of data.entries) { + addChatBubble(entry); + } + chatLineCount = data.total; + } + } catch {} +} + +// ─── Debug Tabs ───────────────────────────────────────────────── + +const debugToggle = document.getElementById('debug-toggle'); +const debugTabs = document.getElementById('debug-tabs'); +const closeDebug = document.getElementById('close-debug'); +let debugOpen = false; + +debugToggle.addEventListener('click', () => { + debugOpen = !debugOpen; + debugToggle.classList.toggle('active', debugOpen); + debugTabs.style.display = debugOpen ? 'flex' : 'none'; + if (!debugOpen) { + // Close debug panels, show chat + document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + document.getElementById('tab-chat').classList.add('active'); + document.querySelectorAll('.debug-tabs .tab').forEach(t => t.classList.remove('active')); + } +}); + +closeDebug.addEventListener('click', () => { + debugOpen = false; + debugToggle.classList.remove('active'); + debugTabs.style.display = 'none'; + document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + document.getElementById('tab-chat').classList.add('active'); +}); + +document.querySelectorAll('.debug-tabs .tab:not(.close-debug)').forEach(tab => { tab.addEventListener('click', () => { - document.querySelectorAll('.tab').forEach(t => { t.classList.remove('active'); t.setAttribute('aria-selected', 'false'); }); + document.querySelectorAll('.debug-tabs .tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); tab.classList.add('active'); - tab.setAttribute('aria-selected', 'true'); document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active'); if (tab.dataset.tab === 'refs') fetchRefs(); }); }); -// ─── Activity Feed ───────────────────────────────────────────── +// ─── Activity Feed ────────────────────────────────────────────── function getEntryClass(entry) { if (entry.status === 'error') return 'error'; @@ -46,6 +169,8 @@ function formatTime(ts) { return d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); } +let pendingEntries = new Map(); + function createEntryElement(entry) { const div = document.createElement('div'); div.className = `activity-entry ${getEntryClass(entry)}`; @@ -76,17 +201,7 @@ function createEntryElement(entry) { ` : ''} `; - // Click to expand/collapse div.addEventListener('click', () => div.classList.toggle('expanded')); - div.addEventListener('keydown', (e) => { - if (e.key === 'Enter') div.classList.toggle('expanded'); - if (e.key === 'Escape') div.classList.remove('expanded'); - }); - - // Screen reader label - const srLabel = `${entry.command || entry.type} ${argsText} ${statusIcon ? (entry.status === 'ok' ? 'succeeded' : 'failed') : 'in progress'} ${duration ? 'in ' + duration : ''}`; - div.setAttribute('aria-label', srLabel); - return div; } @@ -95,9 +210,7 @@ function addEntry(entry) { const empty = document.getElementById('empty-state'); if (empty) empty.style.display = 'none'; - // If command_end, update the matching pending entry if (entry.type === 'command_end') { - // Remove the pending command_start for this command for (const [id, el] of pendingEntries) { if (el.querySelector('.entry-command')?.textContent === entry.command) { el.remove(); @@ -109,21 +222,10 @@ function addEntry(entry) { const el = createEntryElement(entry); feed.appendChild(el); - - if (entry.type === 'command_start') { - pendingEntries.set(entry.id, el); - } - - // Auto-scroll + if (entry.type === 'command_start') pendingEntries.set(entry.id, el); el.scrollIntoView({ behavior: 'smooth', block: 'end' }); - // Update footer - if (entry.url) document.getElementById('footer-url').textContent = new URL(entry.url).hostname; - const parts = []; - if (entry.tabs) parts.push(`${entry.tabs} tabs`); - if (entry.mode) parts.push(entry.mode); - if (parts.length) document.getElementById('footer-info').textContent = parts.join(' \u00b7 '); - + if (entry.url) document.getElementById('footer-url')?.textContent && (document.getElementById('footer-url').textContent = new URL(entry.url).hostname); lastId = Math.max(lastId, entry.id); } @@ -133,24 +235,17 @@ function escapeHtml(str) { return div.innerHTML; } -// ─── SSE Connection ──────────────────────────────────────────── +// ─── SSE Connection ───────────────────────────────────────────── function connectSSE() { if (!serverUrl) return; - - if (eventSource) { - eventSource.close(); - eventSource = null; - } + if (eventSource) { eventSource.close(); eventSource = null; } const url = `${serverUrl}/activity/stream?after=${lastId}`; eventSource = new EventSource(url); eventSource.addEventListener('activity', (e) => { - try { - const entry = JSON.parse(e.data); - addEntry(entry); - } catch {} + try { addEntry(JSON.parse(e.data)); } catch {} }); eventSource.addEventListener('gap', (e) => { @@ -159,17 +254,13 @@ function connectSSE() { const feed = document.getElementById('activity-feed'); const banner = document.createElement('div'); banner.className = 'gap-banner'; - banner.textContent = `Missed ${data.availableFrom - data.gapFrom} events (buffer overflow)`; + banner.textContent = `Missed ${data.availableFrom - data.gapFrom} events`; feed.appendChild(banner); } catch {} }); - - eventSource.onerror = () => { - // EventSource auto-reconnects - }; } -// ─── Refs Tab ────────────────────────────────────────────────── +// ─── Refs Tab ─────────────────────────────────────────────────── async function fetchRefs() { if (!serverUrl) return; @@ -197,29 +288,65 @@ async function fetchRefs() { "${escapeHtml(r.name)}" `).join(''); - footer.textContent = `${data.refs.length} refs \u00b7 ${data.url ? new URL(data.url).hostname : ''}`; + footer.textContent = `${data.refs.length} refs`; } catch {} } -// ─── Server Discovery ────────────────────────────────────────── +// ─── Server Discovery ─────────────────────────────────────────── function updateConnection(url) { serverUrl = url; if (url) { - document.getElementById('header-dot').className = 'dot connected'; + document.getElementById('footer-dot').className = 'dot connected'; const port = new URL(url).port; - document.getElementById('header-port').textContent = `:${port}`; + document.getElementById('footer-port').textContent = `:${port}`; connectSSE(); + // Start chat polling + if (chatPollInterval) clearInterval(chatPollInterval); + chatPollInterval = setInterval(pollChat, 1000); + pollChat(); // immediate first poll } else { - document.getElementById('header-dot').className = 'dot'; - document.getElementById('header-port').textContent = ''; + document.getElementById('footer-dot').className = 'dot'; + document.getElementById('footer-port').textContent = ''; + if (chatPollInterval) { clearInterval(chatPollInterval); chatPollInterval = null; } } } +// ─── Port Configuration ───────────────────────────────────────── + +const portLabel = document.getElementById('footer-port'); +const portInput = document.getElementById('port-input'); + +portLabel.addEventListener('click', () => { + portLabel.style.display = 'none'; + portInput.style.display = ''; + chrome.runtime.sendMessage({ type: 'getPort' }, (resp) => { + portInput.value = resp?.port || ''; + portInput.focus(); + portInput.select(); + }); +}); + +function savePort() { + const port = parseInt(portInput.value, 10); + if (port > 0 && port < 65536) { + chrome.runtime.sendMessage({ type: 'setPort', port }); + } + portInput.style.display = 'none'; + portLabel.style.display = ''; +} +portInput.addEventListener('blur', savePort); +portInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') savePort(); + if (e.key === 'Escape') { portInput.style.display = 'none'; portLabel.style.display = ''; } +}); + chrome.runtime.sendMessage({ type: 'getServerUrl' }, (resp) => { if (resp && resp.url) updateConnection(resp.url); }); +// ─── Message Listener ─────────────────────────────────────────── + chrome.runtime.onMessage.addListener((msg) => { if (msg.type === 'health') { chrome.runtime.sendMessage({ type: 'getServerUrl' }, (resp) => { @@ -227,7 +354,6 @@ chrome.runtime.onMessage.addListener((msg) => { }); } if (msg.type === 'refs') { - // Auto-refresh refs tab if visible if (document.querySelector('.tab[data-tab="refs"].active')) { fetchRefs(); }