/** * gstack browse — Side Panel * * 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']); const INTERACTION_COMMANDS = new Set(['click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait', 'upload']); const OBSERVE_COMMANDS = new Set(['snapshot', 'screenshot', 'diff', 'console', 'network', 'text', 'html', 'links', 'forms', 'accessibility', 'cookies', 'storage', 'perf']); let lastId = 0; let eventSource = null; let serverUrl = null; let serverToken = null; let chatLineCount = 0; let chatPollInterval = null; let connState = 'disconnected'; // disconnected | connected | reconnecting | dead let lastOptimisticMsg = null; // track optimistically rendered user msg to avoid dupes let sidebarActiveTabId = null; // which browser tab's chat we're showing const chatLineCountByTab = {}; // tabId -> last seen chatLineCount const chatDomByTab = {}; // tabId -> saved innerHTML let reconnectAttempts = 0; let reconnectTimer = null; const MAX_RECONNECT_ATTEMPTS = 30; // 30 * 2s = 60s before showing "dead" // Auth headers for sidebar endpoints function authHeaders() { const h = { 'Content-Type': 'application/json' }; if (serverToken) h['Authorization'] = `Bearer ${serverToken}`; 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'); 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' }); } // Current streaming state let agentContainer = null; // The container for the current agent response let agentTextEl = null; // The text accumulator element let agentText = ''; // Accumulated text function addChatEntry(entry) { // Remove welcome message on first real message const welcome = chatMessages.querySelector('.chat-welcome'); if (welcome) welcome.remove(); // User messages → chat bubble (skip if we already rendered it optimistically) if (entry.role === 'user') { if (lastOptimisticMsg === entry.message) { lastOptimisticMsg = null; // consumed — don't skip next identical msg return; } const bubble = document.createElement('div'); bubble.className = 'chat-bubble user'; bubble.innerHTML = `${escapeHtml(entry.message)}${formatChatTime(entry.ts)}`; chatMessages.appendChild(bubble); bubble.scrollIntoView({ behavior: 'smooth', block: 'end' }); return; } // Legacy assistant messages (from /sidebar-response) if (entry.role === 'assistant') { const bubble = document.createElement('div'); bubble.className = 'chat-bubble assistant'; let content = escapeHtml(entry.message); content = content.replace(/```([\s\S]*?)```/g, '
$1
'); content = content.replace(/\*\*(.*?)\*\*/g, '$1'); content = content.replace(/\n/g, '
'); bubble.innerHTML = `${content}${formatChatTime(entry.ts)}`; chatMessages.appendChild(bubble); bubble.scrollIntoView({ behavior: 'smooth', block: 'end' }); return; } // Agent streaming events if (entry.role === 'agent') { handleAgentEvent(entry); return; } } function handleAgentEvent(entry) { if (entry.type === 'agent_start') { // If we already showed thinking dots optimistically in sendMessage(), // don't duplicate. Just ensure fast polling is on. if (agentContainer && document.getElementById('agent-thinking')) { startFastPoll(); updateStopButton(true); return; } // Create a new agent response container agentText = ''; agentContainer = document.createElement('div'); agentContainer.className = 'agent-response'; agentTextEl = null; chatMessages.appendChild(agentContainer); // Add thinking indicator const thinking = document.createElement('div'); thinking.className = 'agent-thinking'; thinking.id = 'agent-thinking'; thinking.innerHTML = ''; agentContainer.appendChild(thinking); agentContainer.scrollIntoView({ behavior: 'smooth', block: 'end' }); startFastPoll(); updateStopButton(true); return; } if (entry.type === 'agent_done') { // Remove thinking indicator const thinking = document.getElementById('agent-thinking'); if (thinking) thinking.remove(); updateStopButton(false); stopFastPoll(); // Add timestamp if (agentContainer) { const ts = document.createElement('span'); ts.className = 'chat-time'; ts.textContent = formatChatTime(entry.ts); agentContainer.appendChild(ts); } agentContainer = null; agentTextEl = null; return; } if (entry.type === 'agent_error') { const thinking = document.getElementById('agent-thinking'); if (thinking) thinking.remove(); updateStopButton(false); stopFastPoll(); if (!agentContainer) { agentContainer = document.createElement('div'); agentContainer.className = 'agent-response'; chatMessages.appendChild(agentContainer); } const err = document.createElement('div'); err.className = 'agent-error'; err.textContent = entry.error || 'Unknown error'; agentContainer.appendChild(err); agentContainer = null; return; } if (!agentContainer) { agentContainer = document.createElement('div'); agentContainer.className = 'agent-response'; chatMessages.appendChild(agentContainer); } // Remove thinking indicator on first real content const thinking = document.getElementById('agent-thinking'); if (thinking) thinking.remove(); if (entry.type === 'tool_use') { const toolEl = document.createElement('div'); toolEl.className = 'agent-tool'; const toolName = entry.tool || 'Tool'; const toolInput = entry.input || ''; // Use the verbose description as the primary text // The tool name becomes a subtle badge const toolIcon = toolName === 'Bash' ? '▸' : toolName === 'Read' ? '📄' : toolName === 'Grep' ? '🔍' : toolName === 'Glob' ? '📁' : '⚡'; toolEl.innerHTML = `${toolIcon} ${escapeHtml(toolInput)}`; agentContainer.appendChild(toolEl); agentContainer.scrollIntoView({ behavior: 'smooth', block: 'end' }); return; } if (entry.type === 'text' || entry.type === 'result') { // Full text replacement agentText = entry.text || ''; if (!agentTextEl) { agentTextEl = document.createElement('div'); agentTextEl.className = 'agent-text'; agentContainer.appendChild(agentTextEl); } let content = escapeHtml(agentText); content = content.replace(/```([\s\S]*?)```/g, '
$1
'); content = content.replace(/\*\*(.*?)\*\*/g, '$1'); content = content.replace(/\n/g, '
'); agentTextEl.innerHTML = content; agentContainer.scrollIntoView({ behavior: 'smooth', block: 'end' }); return; } if (entry.type === 'text_delta') { // Incremental text append agentText += entry.text || ''; if (!agentTextEl) { agentTextEl = document.createElement('div'); agentTextEl.className = 'agent-text'; agentContainer.appendChild(agentTextEl); } let content = escapeHtml(agentText); content = content.replace(/```([\s\S]*?)```/g, '
$1
'); content = content.replace(/\*\*(.*?)\*\*/g, '$1'); content = content.replace(/\n/g, '
'); agentTextEl.innerHTML = content; agentContainer.scrollIntoView({ behavior: 'smooth', block: 'end' }); return; } } 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; // Show user bubble + thinking dots IMMEDIATELY — don't wait for poll. // This eliminates up to 1000ms of perceived latency. lastOptimisticMsg = msg; const welcome = chatMessages.querySelector('.chat-welcome'); if (welcome) welcome.remove(); const userBubble = document.createElement('div'); userBubble.className = 'chat-bubble user'; userBubble.innerHTML = `${escapeHtml(msg)}${formatChatTime(new Date().toISOString())}`; chatMessages.appendChild(userBubble); agentText = ''; agentContainer = document.createElement('div'); agentContainer.className = 'agent-response'; agentTextEl = null; chatMessages.appendChild(agentContainer); const thinking = document.createElement('div'); thinking.className = 'agent-thinking'; thinking.id = 'agent-thinking'; thinking.innerHTML = ''; agentContainer.appendChild(thinking); agentContainer.scrollIntoView({ behavior: 'smooth', block: 'end' }); updateStopButton(true); // Speed up polling while agent is working startFastPoll(); const result = await new Promise((resolve) => { chrome.runtime.sendMessage({ type: 'sidebar-command', message: msg, tabId: sidebarActiveTabId }, resolve); }); commandInput.disabled = false; sendBtn.disabled = false; commandInput.focus(); if (result?.ok) { // Poll immediately to sync server state 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); document.getElementById('stop-agent-btn').addEventListener('click', stopAgent); // Poll for new chat messages let initialLoadDone = false; async function pollChat() { if (!serverUrl || !serverToken) return; try { // Request chat for the currently displayed tab const tabParam = sidebarActiveTabId !== null ? `&tabId=${sidebarActiveTabId}` : ''; const resp = await fetch(`${serverUrl}/sidebar-chat?after=${chatLineCount}${tabParam}`, { headers: authHeaders(), signal: AbortSignal.timeout(3000), }); if (!resp.ok) return; const data = await resp.json(); // Detect tab switch from server — swap chat context if (data.activeTabId !== undefined && data.activeTabId !== sidebarActiveTabId) { switchChatTab(data.activeTabId); return; // switchChatTab triggers a fresh poll } // First successful poll — hide loading spinner if (!initialLoadDone) { initialLoadDone = true; sidebarActiveTabId = data.activeTabId ?? null; const loading = document.getElementById('chat-loading'); const welcome = document.getElementById('chat-welcome'); if (loading) loading.style.display = 'none'; // Show welcome only if no chat history if (data.total === 0 && welcome) welcome.style.display = ''; } if (data.entries && data.entries.length > 0) { // Hide welcome on first real entry const welcome = document.getElementById('chat-welcome'); if (welcome) welcome.style.display = 'none'; for (const entry of data.entries) { addChatEntry(entry); } chatLineCount = data.total; } // Clean up orphaned thinking indicators after replay. const thinking = document.getElementById('agent-thinking'); if (thinking && data.agentStatus !== 'processing') { thinking.remove(); if (agentContainer) { const notice = document.createElement('div'); notice.className = 'agent-text'; notice.style.color = 'var(--text-meta)'; notice.style.fontStyle = 'italic'; notice.textContent = '(session ended)'; agentContainer.appendChild(notice); agentContainer = null; agentTextEl = null; } } // Show/hide stop button based on agent status updateStopButton(data.agentStatus === 'processing'); } catch {} } /** Switch the sidebar to show a different tab's chat context */ function switchChatTab(newTabId) { if (newTabId === sidebarActiveTabId) return; // Save current tab's chat DOM + scroll position if (sidebarActiveTabId !== null) { chatDomByTab[sidebarActiveTabId] = chatMessages.innerHTML; chatLineCountByTab[sidebarActiveTabId] = chatLineCount; } sidebarActiveTabId = newTabId; // Restore saved chat for new tab, or show welcome if (chatDomByTab[newTabId]) { chatMessages.innerHTML = chatDomByTab[newTabId]; chatLineCount = chatLineCountByTab[newTabId] || 0; } else { chatMessages.innerHTML = `
G

Send a message about this page.

Each tab has its own conversation.

`; chatLineCount = 0; } // Reset agent state for this tab agentContainer = null; agentTextEl = null; agentText = ''; // Immediately poll the new tab's chat pollChat(); } function updateStopButton(agentRunning) { const stopBtn = document.getElementById('stop-agent-btn'); if (!stopBtn) return; stopBtn.style.display = agentRunning ? '' : 'none'; } async function stopAgent() { if (!serverUrl) return; try { await fetch(`${serverUrl}/sidebar-agent/stop`, { method: 'POST', headers: authHeaders() }); } catch {} // Immediately clean up UI const thinking = document.getElementById('agent-thinking'); if (thinking) thinking.remove(); if (agentContainer) { const notice = document.createElement('div'); notice.className = 'agent-text'; notice.style.color = 'var(--text-meta)'; notice.style.fontStyle = 'italic'; notice.textContent = 'Stopped'; agentContainer.appendChild(notice); agentContainer = null; agentTextEl = null; } updateStopButton(false); stopFastPoll(); } // ─── Adaptive poll speed ───────────────────────────────────────── // 300ms while agent is working (fast first-token), 1000ms when idle. const FAST_POLL_MS = 300; const SLOW_POLL_MS = 1000; function startFastPoll() { if (chatPollInterval) clearInterval(chatPollInterval); chatPollInterval = setInterval(pollChat, FAST_POLL_MS); } function stopFastPoll() { if (chatPollInterval) clearInterval(chatPollInterval); chatPollInterval = setInterval(pollChat, SLOW_POLL_MS); } // ─── Browser Tab Bar ───────────────────────────────────────────── let tabPollInterval = null; let lastTabJson = ''; async function pollTabs() { if (!serverUrl || !serverToken) return; try { // Tell the server which Chrome tab the user is actually looking at. // This syncs manual tab switches in the browser → server activeTabId. let activeTabUrl = null; try { const chromeTabs = await chrome.tabs.query({ active: true, currentWindow: true }); activeTabUrl = chromeTabs?.[0]?.url || null; } catch {} const resp = await fetch(`${serverUrl}/sidebar-tabs${activeTabUrl ? '?activeUrl=' + encodeURIComponent(activeTabUrl) : ''}`, { headers: authHeaders(), signal: AbortSignal.timeout(2000), }); if (!resp.ok) return; const data = await resp.json(); if (!data.tabs) return; // Only re-render if tabs changed const json = JSON.stringify(data.tabs); if (json === lastTabJson) return; lastTabJson = json; renderTabBar(data.tabs); } catch {} } function renderTabBar(tabs) { const bar = document.getElementById('browser-tabs'); if (!bar) return; if (!tabs || tabs.length <= 1) { bar.style.display = 'none'; return; } bar.style.display = ''; bar.innerHTML = ''; for (const tab of tabs) { const el = document.createElement('div'); el.className = 'browser-tab' + (tab.active ? ' active' : ''); el.title = tab.url || ''; // Show favicon-style domain + title let label = tab.title || ''; if (!label && tab.url) { try { label = new URL(tab.url).hostname; } catch { label = tab.url; } } if (label.length > 20) label = label.slice(0, 20) + '…'; el.textContent = label || `Tab ${tab.id}`; el.dataset.tabId = tab.id; el.addEventListener('click', () => switchBrowserTab(tab.id)); bar.appendChild(el); } } async function switchBrowserTab(tabId) { if (!serverUrl) return; try { await fetch(`${serverUrl}/sidebar-tabs/switch`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ id: tabId }), }); // Switch chat context + re-poll tabs switchChatTab(tabId); pollTabs(); } catch {} } // ─── Clear Chat ───────────────────────────────────────────────── document.getElementById('clear-chat').addEventListener('click', async () => { if (!serverUrl) return; try { await fetch(`${serverUrl}/sidebar-chat/clear`, { method: 'POST', headers: authHeaders() }); } catch {} // Reset local state chatLineCount = 0; agentContainer = null; agentTextEl = null; agentText = ''; chatMessages.innerHTML = `
G

Send a message to Claude Code.

Your agent will see it and act on it.

`; }); // ─── 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('.debug-tabs .tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); tab.classList.add('active'); document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active'); if (tab.dataset.tab === 'refs') fetchRefs(); }); }); // ─── Activity Feed ────────────────────────────────────────────── function getEntryClass(entry) { if (entry.status === 'error') return 'error'; if (entry.type === 'command_start') return 'pending'; const cmd = entry.command || ''; if (NAV_COMMANDS.has(cmd)) return 'nav'; if (INTERACTION_COMMANDS.has(cmd)) return 'interaction'; if (OBSERVE_COMMANDS.has(cmd)) return 'observe'; return ''; } function formatTime(ts) { const d = new Date(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)}`; div.setAttribute('role', 'article'); div.tabIndex = 0; const argsText = entry.args ? entry.args.join(' ') : ''; const statusIcon = entry.status === 'ok' ? '\u2713' : entry.status === 'error' ? '\u2717' : ''; const statusClass = entry.status === 'ok' ? 'ok' : entry.status === 'error' ? 'err' : ''; const duration = entry.duration ? `${entry.duration}ms` : ''; div.innerHTML = `
${formatTime(entry.timestamp)} ${escapeHtml(entry.command || entry.type)}
${argsText ? `
${escapeHtml(argsText)}
` : ''} ${entry.type === 'command_end' ? `
${statusIcon} ${duration}
` : ''} ${entry.result ? `
${escapeHtml(entry.result)}
` : ''} `; div.addEventListener('click', () => div.classList.toggle('expanded')); return div; } function addEntry(entry) { const feed = document.getElementById('activity-feed'); const empty = document.getElementById('empty-state'); if (empty) empty.style.display = 'none'; if (entry.type === 'command_end') { for (const [id, el] of pendingEntries) { if (el.querySelector('.entry-command')?.textContent === entry.command) { el.remove(); pendingEntries.delete(id); break; } } } const el = createEntryElement(entry); feed.appendChild(el); if (entry.type === 'command_start') pendingEntries.set(entry.id, el); el.scrollIntoView({ behavior: 'smooth', block: 'end' }); if (entry.url) document.getElementById('footer-url')?.textContent && (document.getElementById('footer-url').textContent = new URL(entry.url).hostname); lastId = Math.max(lastId, entry.id); } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } // ─── SSE Connection ───────────────────────────────────────────── function connectSSE() { if (!serverUrl) return; if (eventSource) { eventSource.close(); eventSource = null; } const tokenParam = serverToken ? `&token=${serverToken}` : ''; const url = `${serverUrl}/activity/stream?after=${lastId}${tokenParam}`; eventSource = new EventSource(url); eventSource.addEventListener('activity', (e) => { try { addEntry(JSON.parse(e.data)); } catch {} }); eventSource.addEventListener('gap', (e) => { try { const data = JSON.parse(e.data); const feed = document.getElementById('activity-feed'); const banner = document.createElement('div'); banner.className = 'gap-banner'; banner.textContent = `Missed ${data.availableFrom - data.gapFrom} events`; feed.appendChild(banner); } catch {} }); } // ─── Refs Tab ─────────────────────────────────────────────────── async function fetchRefs() { if (!serverUrl) return; try { const headers = {}; if (serverToken) headers['Authorization'] = `Bearer ${serverToken}`; const resp = await fetch(`${serverUrl}/refs`, { signal: AbortSignal.timeout(3000), headers }); if (!resp.ok) return; const data = await resp.json(); const list = document.getElementById('refs-list'); const empty = document.getElementById('refs-empty'); const footer = document.getElementById('refs-footer'); if (!data.refs || data.refs.length === 0) { empty.style.display = ''; list.innerHTML = ''; footer.textContent = ''; return; } empty.style.display = 'none'; list.innerHTML = data.refs.map(r => `
${escapeHtml(r.ref)} ${escapeHtml(r.role)} "${escapeHtml(r.name)}"
`).join(''); footer.textContent = `${data.refs.length} refs`; } catch {} } // ─── Inspector Tab ────────────────────────────────────────────── let inspectorPickerActive = false; let inspectorData = null; // last inspect result let inspectorModifications = []; // tracked style changes let inspectorSSE = null; // Inspector DOM refs const inspectorPickBtn = document.getElementById('inspector-pick-btn'); const inspectorSelected = document.getElementById('inspector-selected'); const inspectorModeBadge = document.getElementById('inspector-mode-badge'); const inspectorEmpty = document.getElementById('inspector-empty'); const inspectorLoading = document.getElementById('inspector-loading'); const inspectorError = document.getElementById('inspector-error'); const inspectorPanels = document.getElementById('inspector-panels'); const inspectorBoxmodel = document.getElementById('inspector-boxmodel'); const inspectorRules = document.getElementById('inspector-rules'); const inspectorRuleCount = document.getElementById('inspector-rule-count'); const inspectorComputed = document.getElementById('inspector-computed'); const inspectorQuickedit = document.getElementById('inspector-quickedit'); const inspectorSend = document.getElementById('inspector-send'); const inspectorSendBtn = document.getElementById('inspector-send-btn'); // Pick button inspectorPickBtn.addEventListener('click', () => { if (inspectorPickerActive) { inspectorPickerActive = false; inspectorPickBtn.classList.remove('active'); chrome.runtime.sendMessage({ type: 'stopInspector' }); } else { inspectorPickerActive = true; inspectorPickBtn.classList.add('active'); inspectorShowLoading(false); // don't show loading yet, just activate chrome.runtime.sendMessage({ type: 'startInspector' }, (result) => { if (result?.error) { inspectorPickerActive = false; inspectorPickBtn.classList.remove('active'); inspectorShowError(result.error); } }); } }); function inspectorShowEmpty() { inspectorEmpty.style.display = ''; inspectorLoading.style.display = 'none'; inspectorError.style.display = 'none'; inspectorPanels.style.display = 'none'; inspectorSend.style.display = 'none'; } function inspectorShowLoading(show) { if (show) { inspectorEmpty.style.display = 'none'; inspectorLoading.style.display = ''; inspectorError.style.display = 'none'; inspectorPanels.style.display = 'none'; } else { inspectorLoading.style.display = 'none'; } } function inspectorShowError(message) { inspectorEmpty.style.display = 'none'; inspectorLoading.style.display = 'none'; inspectorError.style.display = ''; inspectorError.textContent = message; inspectorPanels.style.display = 'none'; } function inspectorShowData(data) { inspectorData = data; inspectorModifications = []; inspectorEmpty.style.display = 'none'; inspectorLoading.style.display = 'none'; inspectorError.style.display = 'none'; inspectorPanels.style.display = ''; inspectorSend.style.display = ''; // Update toolbar const tag = data.tagName || '?'; const cls = data.classes && data.classes.length > 0 ? '.' + data.classes.join('.') : ''; const idStr = data.id ? '#' + data.id : ''; inspectorSelected.textContent = `<${tag}>${idStr}${cls}`; inspectorSelected.title = data.selector; // Mode badge if (data.mode === 'basic') { inspectorModeBadge.textContent = 'Basic mode'; inspectorModeBadge.style.display = ''; inspectorModeBadge.className = 'inspector-mode-badge basic'; } else if (data.mode === 'cdp') { inspectorModeBadge.textContent = 'CDP'; inspectorModeBadge.style.display = ''; inspectorModeBadge.className = 'inspector-mode-badge cdp'; } else { inspectorModeBadge.style.display = 'none'; } // Render sections renderBoxModel(data); renderMatchedRules(data); renderComputedStyles(data); renderQuickEdit(data); updateSendButton(); } // ─── Box Model Rendering ──────────────────────────────────────── function renderBoxModel(data) { const box = data.basicData?.boxModel || data.boxModel; if (!box) { inspectorBoxmodel.innerHTML = 'No box model data'; return; } const m = box.margin || {}; const b = box.border || {}; const p = box.padding || {}; const c = box.content || {}; inspectorBoxmodel.innerHTML = `
margin ${fmtBoxVal(m.top)} ${fmtBoxVal(m.right)} ${fmtBoxVal(m.bottom)} ${fmtBoxVal(m.left)}
border ${fmtBoxVal(b.top)} ${fmtBoxVal(b.right)} ${fmtBoxVal(b.bottom)} ${fmtBoxVal(b.left)}
padding ${fmtBoxVal(p.top)} ${fmtBoxVal(p.right)} ${fmtBoxVal(p.bottom)} ${fmtBoxVal(p.left)}
${Math.round(c.width || 0)} x ${Math.round(c.height || 0)}
`; } function fmtBoxVal(v) { if (v === undefined || v === null) return '-'; const n = typeof v === 'number' ? v : parseFloat(v); if (isNaN(n) || n === 0) return '0'; return Math.round(n * 10) / 10; } // ─── Matched Rules Rendering ──────────────────────────────────── function renderMatchedRules(data) { const rules = data.matchedRules || data.basicData?.matchedRules || []; inspectorRuleCount.textContent = rules.length > 0 ? `(${rules.length})` : ''; if (rules.length === 0) { inspectorRules.innerHTML = '
No matched rules
'; return; } // Separate UA rules from author rules const authorRules = []; const uaRules = []; for (const rule of rules) { if (rule.origin === 'user-agent' || rule.isUA) { uaRules.push(rule); } else { authorRules.push(rule); } } let html = ''; // Author rules (expanded) for (const rule of authorRules) { html += renderRule(rule, false); } // UA rules (collapsed by default) if (uaRules.length > 0) { html += `
'; } inspectorRules.innerHTML = html; // Bind UA toggle const uaToggle = inspectorRules.querySelector('.inspector-ua-toggle'); if (uaToggle) { uaToggle.addEventListener('click', () => { const body = inspectorRules.querySelector('.inspector-ua-body'); const isCollapsed = uaToggle.classList.contains('collapsed'); uaToggle.classList.toggle('collapsed', !isCollapsed); uaToggle.setAttribute('aria-expanded', isCollapsed); uaToggle.querySelector('.inspector-toggle-arrow').innerHTML = isCollapsed ? '▼' : '▶'; body.classList.toggle('collapsed', !isCollapsed); }); } } function renderRule(rule, isUA) { const selectorText = escapeHtml(rule.selector || ''); const truncatedSelector = selectorText.length > 35 ? selectorText.slice(0, 35) + '...' : selectorText; const source = rule.source || ''; const sourceDisplay = source.includes('/') ? source.split('/').pop() : source; const specificity = rule.specificity || ''; let propsHtml = ''; const props = rule.properties || []; for (const prop of props) { const overridden = prop.overridden ? ' overridden' : ''; const nameHtml = escapeHtml(prop.name); const valText = escapeHtml(prop.value || ''); const truncatedVal = valText.length > 30 ? valText.slice(0, 30) + '...' : valText; const priority = prop.priority === 'important' ? ' !important' : ''; propsHtml += `
${nameHtml}: ${truncatedVal}${priority};
`; } return `
${truncatedSelector} ${specificity ? `${escapeHtml(specificity)}` : ''}
${propsHtml}
${sourceDisplay ? `
${escapeHtml(sourceDisplay)}
` : ''}
`; } // ─── Computed Styles Rendering ────────────────────────────────── function renderComputedStyles(data) { const styles = data.computedStyles || data.basicData?.computedStyles || {}; const keys = Object.keys(styles); if (keys.length === 0) { inspectorComputed.innerHTML = '
No computed styles
'; return; } let html = ''; for (const key of keys) { const val = styles[key]; if (!val || val === 'none' || val === 'normal' || val === 'auto' || val === '0px' || val === 'rgba(0, 0, 0, 0)') continue; html += `
${escapeHtml(key)}: ${escapeHtml(val)}
`; } if (!html) { html = '
All values are defaults
'; } inspectorComputed.innerHTML = html; } // ─── Quick Edit ───────────────────────────────────────────────── function renderQuickEdit(data) { const selector = data.selector; if (!selector) { inspectorQuickedit.innerHTML = ''; return; } // Show common editable properties with current values const editableProps = ['color', 'background-color', 'font-size', 'padding', 'margin', 'border', 'display', 'opacity']; const computed = data.computedStyles || data.basicData?.computedStyles || {}; let html = '
'; for (const prop of editableProps) { const val = computed[prop] || ''; html += `
${escapeHtml(prop)}: ${escapeHtml(val || '(none)')}
`; } html += '
'; inspectorQuickedit.innerHTML = html; // Bind click-to-edit inspectorQuickedit.querySelectorAll('.inspector-quickedit-value').forEach(el => { el.addEventListener('click', () => startQuickEdit(el)); el.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); startQuickEdit(el); } }); }); } function startQuickEdit(valueEl) { if (valueEl.querySelector('input')) return; // already editing const currentVal = valueEl.textContent === '(none)' ? '' : valueEl.textContent; const prop = valueEl.dataset.prop; const selector = valueEl.dataset.selector; const input = document.createElement('input'); input.type = 'text'; input.className = 'inspector-quickedit-input'; input.value = currentVal; valueEl.textContent = ''; valueEl.appendChild(input); input.focus(); input.select(); function commit() { const newVal = input.value.trim(); valueEl.textContent = newVal || '(none)'; if (newVal && newVal !== currentVal) { chrome.runtime.sendMessage({ type: 'applyStyle', selector, property: prop, value: newVal, }); inspectorModifications.push({ property: prop, value: newVal, selector }); updateSendButton(); } } function cancel() { valueEl.textContent = currentVal || '(none)'; } input.addEventListener('blur', commit); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } if (e.key === 'Escape') { e.preventDefault(); input.removeEventListener('blur', commit); cancel(); } }); } // ─── Send to Agent ────────────────────────────────────────────── function updateSendButton() { if (inspectorModifications.length > 0) { inspectorSendBtn.textContent = 'Send to Code'; inspectorSendBtn.title = `${inspectorModifications.length} modification(s) to send`; } else { inspectorSendBtn.textContent = 'Send to Agent'; inspectorSendBtn.title = 'Send full inspector data'; } } inspectorSendBtn.addEventListener('click', () => { if (!inspectorData) return; let message; if (inspectorModifications.length > 0) { // Format modification diff const diffs = inspectorModifications.map(m => ` ${m.property}: ${m.value} (selector: ${m.selector})` ).join('\n'); message = `CSS Inspector modifications:\n\nSelector: ${inspectorData.selector}\n\nChanges:\n${diffs}`; // Include source file info if available const rules = inspectorData.matchedRules || inspectorData.basicData?.matchedRules || []; const sources = rules.filter(r => r.source && r.source !== 'inline').map(r => r.source); if (sources.length > 0) { message += `\n\nSource files:\n${[...new Set(sources)].map(s => ` ${s}`).join('\n')}`; } } else { // Send full inspector data message = `CSS Inspector data for: ${inspectorData.selector}\n\n${JSON.stringify(inspectorData, null, 2)}`; } chrome.runtime.sendMessage({ type: 'sidebar-command', message }); }); // ─── Section Toggles ──────────────────────────────────────────── document.querySelectorAll('.inspector-section-toggle').forEach(toggle => { toggle.addEventListener('click', () => { const section = toggle.dataset.section; const body = document.getElementById(`inspector-${section}`); const isCollapsed = toggle.classList.contains('collapsed'); toggle.classList.toggle('collapsed', !isCollapsed); toggle.setAttribute('aria-expanded', isCollapsed); toggle.querySelector('.inspector-toggle-arrow').innerHTML = isCollapsed ? '▼' : '▶'; body.classList.toggle('collapsed', !isCollapsed); }); }); // ─── Inspector SSE ────────────────────────────────────────────── function connectInspectorSSE() { if (!serverUrl || !serverToken) return; if (inspectorSSE) { inspectorSSE.close(); inspectorSSE = null; } const tokenParam = serverToken ? `&token=${serverToken}` : ''; const url = `${serverUrl}/inspector/events?_=${Date.now()}${tokenParam}`; try { inspectorSSE = new EventSource(url); inspectorSSE.addEventListener('inspectResult', (e) => { try { const data = JSON.parse(e.data); inspectorShowData(data); } catch {} }); inspectorSSE.addEventListener('error', () => { // SSE connection failed — inspector works without it (basic mode) if (inspectorSSE) { inspectorSSE.close(); inspectorSSE = null; } }); } catch { // SSE not available — that's fine } } // ─── 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(); connectInspectorSSE(); if (chatPollInterval) clearInterval(chatPollInterval); chatPollInterval = setInterval(pollChat, SLOW_POLL_MS); pollChat(); // Poll browser tabs every 2s (lightweight, just tab list) if (tabPollInterval) clearInterval(tabPollInterval); tabPollInterval = setInterval(pollTabs, 2000); pollTabs(); } else { document.getElementById('footer-dot').className = 'dot'; document.getElementById('footer-port').textContent = ''; if (chatPollInterval) { clearInterval(chatPollInterval); chatPollInterval = null; } if (tabPollInterval) { clearInterval(tabPollInterval); tabPollInterval = null; } if (wasConnected) { startReconnect(); } } } // ─── 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 = ''; } }); // ─── 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) => { if (resp && resp.port && resp.connected) { const url = `http://127.0.0.1:${resp.port}`; // Token arrives via health broadcast from background.js updateConnection(url, null); } else { setTimeout(tryConnect, 2000); } }); } tryConnect(); // ─── Message Listener ─────────────────────────────────────────── chrome.runtime.onMessage.addListener((msg) => { if (msg.type === 'health') { if (msg.data) { const url = `http://127.0.0.1:${msg.data.port || 34567}`; updateConnection(url, msg.data.token); applyChatEnabled(!!msg.data.chatEnabled); } else { updateConnection(null); } } if (msg.type === 'refs') { if (document.querySelector('.tab[data-tab="refs"].active')) { fetchRefs(); } } if (msg.type === 'inspectResult') { inspectorPickerActive = false; inspectorPickBtn.classList.remove('active'); if (msg.data) { inspectorShowData(msg.data); } else { inspectorShowError('Element not found, try picking again'); } } if (msg.type === 'pickerCancelled') { inspectorPickerActive = false; inspectorPickBtn.classList.remove('active'); } // Instant tab switch — background.js fires this on chrome.tabs.onActivated if (msg.type === 'browserTabActivated') { // Tell the server which tab is now active, then switch chat context if (serverUrl && serverToken) { fetch(`${serverUrl}/sidebar-tabs?activeUrl=${encodeURIComponent(msg.url || '')}`, { headers: authHeaders(), signal: AbortSignal.timeout(2000), }).then(r => r.json()).then(data => { if (data.tabs) { renderTabBar(data.tabs); // Find the server-side tab ID for this Chrome tab const activeTab = data.tabs.find(t => t.active); if (activeTab && activeTab.id !== sidebarActiveTabId) { switchChatTab(activeTab.id); } } }).catch(() => {}); } } }); // ─── Chat Gate ────────────────────────────────────────────────── // Show/hide Chat tab + command bar based on chatEnabled from server function applyChatEnabled(enabled) { const commandBar = document.querySelector('.command-bar'); const chatTab = document.getElementById('tab-chat'); const banner = document.getElementById('experimental-banner'); const clearBtn = document.getElementById('clear-chat'); if (enabled) { // Chat is enabled: show command bar, chat tab, experimental banner if (commandBar) commandBar.style.display = ''; if (chatTab) chatTab.style.display = ''; if (banner) banner.style.display = ''; if (clearBtn) clearBtn.style.display = ''; } else { // Chat disabled: hide command bar, chat content, clear button if (commandBar) commandBar.style.display = 'none'; if (banner) banner.style.display = 'none'; if (clearBtn) clearBtn.style.display = 'none'; // If currently on chat tab, switch to activity if (chatTab && chatTab.classList.contains('active')) { chatTab.classList.remove('active'); // Open debug tabs and show activity const debugToggle = document.getElementById('debug-toggle'); const debugTabs = document.getElementById('debug-tabs'); if (debugToggle) debugToggle.classList.add('active'); if (debugTabs) debugTabs.style.display = 'flex'; const activityTab = document.getElementById('tab-activity'); if (activityTab) activityTab.classList.add('active'); const activityBtn = document.querySelector('.tab[data-tab="activity"]'); if (activityBtn) activityBtn.classList.add('active'); } } }