Files
gstack/extension/sidepanel.js
T
Garry Tan f395f58406 feat: sidebar CSS inspector — element picker, box model, rule cascade, quick edit
Extension changes for the visual CSS inspector:
- inspector.js: element picker with hover highlight, CSS selector generation,
  basic mode fallback (getComputedStyle + CSSOM), page alteration handlers
- inspector.css: picker overlay styles (blue highlight + tooltip)
- background.js: inspector message routing (picker <-> server <-> sidepanel)
- sidepanel: Inspector tab with box model viz (gstack palette), matched rules
  with specificity badges, computed styles, click-to-edit quick edit,
  Send to Agent/Code button, empty/loading/error states

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:25:36 -07:00

1100 lines
40 KiB
JavaScript

/**
* 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 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
if (entry.role === 'user') {
const bubble = document.createElement('div');
bubble.className = 'chat-bubble user';
bubble.innerHTML = `${escapeHtml(entry.message)}<span class="chat-time">${formatChatTime(entry.ts)}</span>`;
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, '<pre>$1</pre>');
content = content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
content = content.replace(/\n/g, '<br>');
bubble.innerHTML = `${content}<span class="chat-time">${formatChatTime(entry.ts)}</span>`;
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') {
// 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 = '<span class="thinking-dot"></span><span class="thinking-dot"></span><span class="thinking-dot"></span>';
agentContainer.appendChild(thinking);
agentContainer.scrollIntoView({ behavior: 'smooth', block: 'end' });
return;
}
if (entry.type === 'agent_done') {
// Remove thinking indicator
const thinking = document.getElementById('agent-thinking');
if (thinking) thinking.remove();
// 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();
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 || '';
toolEl.innerHTML = `<span class="tool-name">${escapeHtml(toolName)}</span> <span class="tool-input">${escapeHtml(toolInput)}</span>`;
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, '<pre>$1</pre>');
content = content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
content = content.replace(/\n/g, '<br>');
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, '<pre>$1</pre>');
content = content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
content = content.replace(/\n/g, '<br>');
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;
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
let initialLoadDone = false;
async function pollChat() {
if (!serverUrl || !serverToken) return;
try {
const resp = await fetch(`${serverUrl}/sidebar-chat?after=${chatLineCount}`, {
headers: authHeaders(),
signal: AbortSignal.timeout(3000),
});
if (!resp.ok) return;
const data = await resp.json();
// First successful poll — hide loading spinner
if (!initialLoadDone) {
initialLoadDone = true;
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;
}
} 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 = `
<div class="chat-welcome" id="chat-welcome">
<div class="chat-welcome-icon">G</div>
<p>Send a message to Claude Code.</p>
<p class="muted">Your agent will see it and act on it.</p>
</div>`;
});
// ─── 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 = `
<div class="entry-header">
<span class="entry-time">${formatTime(entry.timestamp)}</span>
<span class="entry-command">${escapeHtml(entry.command || entry.type)}</span>
</div>
${argsText ? `<div class="entry-args">${escapeHtml(argsText)}</div>` : ''}
${entry.type === 'command_end' ? `
<div class="entry-status">
<span class="${statusClass}">${statusIcon}</span>
<span class="duration">${duration}</span>
</div>
` : ''}
${entry.result ? `
<div class="entry-detail">
<div class="entry-result">${escapeHtml(entry.result)}</div>
</div>
` : ''}
`;
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 => `
<div class="ref-row">
<span class="ref-id">${escapeHtml(r.ref)}</span>
<span class="ref-role">${escapeHtml(r.role)}</span>
<span class="ref-name">"${escapeHtml(r.name)}"</span>
</div>
`).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 = '<span class="inspector-no-data">No box model data</span>'; return; }
const m = box.margin || {};
const b = box.border || {};
const p = box.padding || {};
const c = box.content || {};
inspectorBoxmodel.innerHTML = `
<div class="boxmodel-margin">
<span class="boxmodel-label">margin</span>
<span class="boxmodel-value boxmodel-top">${fmtBoxVal(m.top)}</span>
<span class="boxmodel-value boxmodel-right">${fmtBoxVal(m.right)}</span>
<span class="boxmodel-value boxmodel-bottom">${fmtBoxVal(m.bottom)}</span>
<span class="boxmodel-value boxmodel-left">${fmtBoxVal(m.left)}</span>
<div class="boxmodel-border">
<span class="boxmodel-label">border</span>
<span class="boxmodel-value boxmodel-top">${fmtBoxVal(b.top)}</span>
<span class="boxmodel-value boxmodel-right">${fmtBoxVal(b.right)}</span>
<span class="boxmodel-value boxmodel-bottom">${fmtBoxVal(b.bottom)}</span>
<span class="boxmodel-value boxmodel-left">${fmtBoxVal(b.left)}</span>
<div class="boxmodel-padding">
<span class="boxmodel-label">padding</span>
<span class="boxmodel-value boxmodel-top">${fmtBoxVal(p.top)}</span>
<span class="boxmodel-value boxmodel-right">${fmtBoxVal(p.right)}</span>
<span class="boxmodel-value boxmodel-bottom">${fmtBoxVal(p.bottom)}</span>
<span class="boxmodel-value boxmodel-left">${fmtBoxVal(p.left)}</span>
<div class="boxmodel-content">
<span>${Math.round(c.width || 0)} x ${Math.round(c.height || 0)}</span>
</div>
</div>
</div>
</div>
`;
}
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 = '<div class="inspector-no-data">No matched rules</div>';
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 += `
<div class="inspector-ua-rules">
<button class="inspector-ua-toggle collapsed" aria-expanded="false">
<span class="inspector-toggle-arrow">&#x25B6;</span>
User Agent (${uaRules.length})
</button>
<div class="inspector-ua-body collapsed">
`;
for (const rule of uaRules) {
html += renderRule(rule, true);
}
html += '</div></div>';
}
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 ? '&#x25BC;' : '&#x25B6;';
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' ? ' <span class="inspector-important">!important</span>' : '';
propsHtml += `<div class="inspector-prop${overridden}"><span class="inspector-prop-name">${nameHtml}</span>: <span class="inspector-prop-value" title="${valText}">${truncatedVal}</span>${priority};</div>`;
}
return `
<div class="inspector-rule" role="treeitem">
<div class="inspector-rule-header">
<span class="inspector-selector" title="${selectorText}">${truncatedSelector}</span>
${specificity ? `<span class="inspector-specificity">${escapeHtml(specificity)}</span>` : ''}
</div>
<div class="inspector-rule-props">${propsHtml}</div>
${sourceDisplay ? `<div class="inspector-rule-source">${escapeHtml(sourceDisplay)}</div>` : ''}
</div>
`;
}
// ─── Computed Styles Rendering ──────────────────────────────────
function renderComputedStyles(data) {
const styles = data.computedStyles || data.basicData?.computedStyles || {};
const keys = Object.keys(styles);
if (keys.length === 0) {
inspectorComputed.innerHTML = '<div class="inspector-no-data">No computed styles</div>';
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 += `<div class="inspector-computed-row"><span class="inspector-prop-name">${escapeHtml(key)}</span>: <span class="inspector-prop-value">${escapeHtml(val)}</span></div>`;
}
if (!html) {
html = '<div class="inspector-no-data">All values are defaults</div>';
}
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 = '<div class="inspector-quickedit-list">';
for (const prop of editableProps) {
const val = computed[prop] || '';
html += `
<div class="inspector-quickedit-row" data-prop="${escapeHtml(prop)}">
<span class="inspector-prop-name">${escapeHtml(prop)}</span>:
<span class="inspector-quickedit-value" data-selector="${escapeHtml(selector)}" data-prop="${escapeHtml(prop)}" tabindex="0" role="button" title="Click to edit">${escapeHtml(val || '(none)')}</span>
</div>
`;
}
html += '</div>';
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 ? '&#x25BC;' : '&#x25B6;';
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, 1000);
pollChat();
} else {
document.getElementById('footer-dot').className = 'dot';
document.getElementById('footer-port').textContent = '';
if (chatPollInterval) { clearInterval(chatPollInterval); chatPollInterval = 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');
}
});
// ─── 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');
}
}
}