mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 11:45:20 +02:00
03973c2fab
* fix(bin): pass search params via env vars (RCE fix) (#819) Replace shell string interpolation with process.env in gstack-learnings-search to prevent arbitrary code execution via crafted learnings entries. Also fixes the CROSS_PROJECT interpolation that the original PR missed. Adds 3 regression tests verifying no shell interpolation remains in the bun -e block. Co-authored-by: garagon <garagon@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(browse): add path validation to upload command (#821) Add isPathWithin() and path traversal checks to the upload command, blocking file exfiltration via crafted upload paths. Uses existing SAFE_DIRECTORIES constant instead of a local copy. Adds 3 regression tests. Co-authored-by: garagon <garagon@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(browse): symlink resolution in meta-commands validateOutputPath (#820) Add realpathSync to validateOutputPath in meta-commands.ts to catch symlink-based directory escapes in screenshot, pdf, and responsive commands. Resolves SAFE_DIRECTORIES through realpathSync to handle macOS /tmp -> /private/tmp symlinks. Existing path validation tests pass with the hardened implementation. Co-authored-by: garagon <garagon@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add uninstall instructions to README (#812) Community PR #812 by @0531Kim. Adds two uninstall paths: the gstack-uninstall script (handles everything) and manual removal steps for when the repo isn't cloned. Includes CLAUDE.md cleanup note and Playwright cache guidance. Co-Authored-By: 0531Kim <0531Kim@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(browse): Windows launcher extraEnv + headed-mode token (#822) Community PR #822 by @pieterklue. Three fixes: 1. Windows launcher now merges extraEnv into spawned server env (was only passing BROWSE_STATE_FILE, dropping all other env vars) 2. Welcome page fallback serves inline HTML instead of about:blank redirect (avoids ERR_UNSAFE_REDIRECT on Windows) 3. /health returns auth token in headed mode even without Origin header (fixes Playwright Chromium extensions that don't send it) Also adds HOME/USERPROFILE fallback for cross-platform compatibility. Co-Authored-By: pieterklue <pieterklue@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(browse): terminate orphan server when parent process exits (#808) Community PR #808 by @mmporong. Passes BROWSE_PARENT_PID to the spawned server process. The server polls every 15s with signal 0 and calls shutdown() if the parent is gone. Prevents orphaned chrome-headless-shell processes when Claude Code sessions exit abnormally. Co-Authored-By: mmporong <mmporong@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(security): IPv6 ULA blocking, cookie redaction, per-tab cancel, targeted token (#664) Community PR #664 by @mr-k-man (security audit round 1, new parts only). - IPv6 ULA prefix blocking (fc00::/7) in url-validation.ts with false-positive guard for hostnames like fd.example.com - Cookie value redaction for tokens, API keys, JWTs in browse cookies command - Per-tab cancel files in killAgent() replacing broken global kill-signal - design/serve.ts: realpathSync upgrade prevents symlink bypass in /api/reload - extension: targeted getToken handler replaces token-in-health-broadcast - Supabase migration 003: column-level GRANT restricts anon UPDATE scope - Telemetry sync: upsert error logging - 10 new tests for IPv6, cookie redaction, DNS rebinding, path traversal Co-Authored-By: mr-k-man <mr-k-man@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(security): CSS injection guard, timeout clamping, session validation, tests (#806) Community PR #806 by @mr-k-man (security audit round 2, new parts only). - CSS value validation (DANGEROUS_CSS) in cdp-inspector, write-commands, extension inspector - Queue file permissions (0o700/0o600) in cli, server, sidebar-agent - escapeRegExp for frame --url ReDoS fix - Responsive screenshot path validation with validateOutputPath - State load cookie filtering (reject localhost/.internal/metadata cookies) - Session ID format validation in loadSession - /health endpoint: remove currentUrl and currentMessage fields - QueueEntry interface + isValidQueueEntry validator for sidebar-agent - SIGTERM->SIGKILL escalation in timeout handler - Viewport dimension clamping (1-16384), wait timeout clamping (1s-300s) - Cookie domain validation in cookie-import and cookie-import-browser - DocumentFragment-based tab switching (XSS fix in sidepanel) - pollInProgress reentrancy guard for pollChat - toggleClass/injectCSS input validation in extension inspector - Snapshot annotated path validation with realpathSync - 714-line security-audit-r2.test.ts + 33-line learnings-injection.test.ts Co-Authored-By: mr-k-man <mr-k-man@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.15.13.0) Community security wave: 8 PRs from 4 contributors (@garagon, @mr-k-man, @mmporong, @0531Kim, @pieterklue). IPv6 ULA blocking, cookie redaction, per-tab cancel signaling, CSS injection guards, timeout clamping, session validation, DocumentFragment XSS fix, parent process watchdog, uninstall docs, Windows fixes, and 750+ lines of security regression tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: garagon <garagon@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: 0531Kim <0531Kim@users.noreply.github.com> Co-authored-by: pieterklue <pieterklue@users.noreply.github.com> Co-authored-by: mmporong <mmporong@users.noreply.github.com> Co-authored-by: mr-k-man <mr-k-man@users.noreply.github.com>
1676 lines
62 KiB
JavaScript
1676 lines
62 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 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 DocumentFragment (never serialized HTML)
|
|
let pollInProgress = false; // reentrancy guard — prevents concurrent/recursive pollChat calls
|
|
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
|
|
|
|
// Dedup: track which entry IDs have already been rendered to prevent
|
|
// repeat rendering on reconnect or tab switch (server replays from disk)
|
|
const renderedEntryIds = new Set();
|
|
|
|
function addChatEntry(entry) {
|
|
// Dedup by entry ID — prevent repeat rendering on reconnect/replay
|
|
if (entry.id !== undefined) {
|
|
if (renderedEntryIds.has(entry.id)) return;
|
|
renderedEntryIds.add(entry.id);
|
|
}
|
|
|
|
// 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)}<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;
|
|
}
|
|
|
|
// System notifications (cleanup, screenshot, errors)
|
|
if (entry.type === 'notification') {
|
|
const note = document.createElement('div');
|
|
note.className = 'chat-notification';
|
|
note.textContent = entry.message;
|
|
chatMessages.appendChild(note);
|
|
note.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 = '<span class="thinking-dot"></span><span class="thinking-dot"></span><span class="thinking-dot"></span>';
|
|
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();
|
|
// Collapse tool calls into a "See reasoning" disclosure
|
|
if (agentContainer) {
|
|
const tools = agentContainer.querySelectorAll('.agent-tool');
|
|
if (tools.length > 0) {
|
|
const details = document.createElement('details');
|
|
details.className = 'agent-reasoning';
|
|
const summary = document.createElement('summary');
|
|
summary.textContent = `See reasoning (${tools.length} step${tools.length > 1 ? 's' : ''})`;
|
|
details.appendChild(summary);
|
|
for (const tool of tools) {
|
|
details.appendChild(tool);
|
|
}
|
|
// Insert the disclosure before the text response (if any)
|
|
const textEl = agentContainer.querySelector('.agent-text');
|
|
if (textEl) {
|
|
agentContainer.insertBefore(details, textEl);
|
|
} else {
|
|
agentContainer.appendChild(details);
|
|
}
|
|
}
|
|
// Add timestamp
|
|
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') {
|
|
// Suppress timeout errors that fire after agent_done (cleanup noise)
|
|
if (entry.error && entry.error.includes('Timed out') && !agentContainer) {
|
|
return;
|
|
}
|
|
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 toolName = entry.tool || 'Tool';
|
|
const toolInput = entry.input || '';
|
|
|
|
// Skip tool uses with no description (e.g. internal tool-result file reads)
|
|
if (!toolInput) return;
|
|
|
|
const toolEl = document.createElement('div');
|
|
toolEl.className = 'agent-tool';
|
|
|
|
// 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 = `<span class="tool-icon">${toolIcon}</span> <span class="tool-description">${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;
|
|
|
|
// 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)}<span class="chat-time">${formatChatTime(new Date().toISOString())}</span>`;
|
|
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 = '<span class="thinking-dot"></span><span class="thinking-dot"></span><span class="thinking-dot"></span>';
|
|
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 (pollInProgress) return;
|
|
pollInProgress = true;
|
|
if (!serverUrl || !serverToken) { pollInProgress = false; 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) {
|
|
console.warn(`[gstack sidebar] Chat poll failed: ${resp.status} ${resp.statusText}`);
|
|
return;
|
|
}
|
|
const data = await resp.json();
|
|
|
|
// Detect tab switch from server — swap chat context.
|
|
// IMPORTANT: return before cleaning up thinking dots — the agent may be
|
|
// processing on the NEW tab while the OLD tab is idle. Removing the
|
|
// thinking indicator here would kill the optimistic UI before the switch.
|
|
if (data.activeTabId !== undefined && data.activeTabId !== sidebarActiveTabId) {
|
|
switchChatTab(data.activeTabId);
|
|
return; // switchChatTab triggers a fresh poll on the correct tab
|
|
}
|
|
|
|
// 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.
|
|
// Only remove if we're on the CORRECT tab and the agent is truly idle.
|
|
// Don't clean up during tab switches — the agent may be processing on
|
|
// the new tab while the old tab shows idle.
|
|
const thinking = document.getElementById('agent-thinking');
|
|
if (thinking && data.agentStatus !== 'processing') {
|
|
thinking.remove();
|
|
agentContainer = null;
|
|
agentTextEl = null;
|
|
}
|
|
|
|
// Show/hide stop button based on agent status
|
|
updateStopButton(data.agentStatus === 'processing');
|
|
} catch (err) {
|
|
console.error('[gstack sidebar] Chat poll error:', err.message);
|
|
} finally {
|
|
pollInProgress = false;
|
|
}
|
|
}
|
|
|
|
/** 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) {
|
|
const frag = document.createDocumentFragment();
|
|
while (chatMessages.firstChild) {
|
|
frag.appendChild(chatMessages.firstChild);
|
|
}
|
|
chatDomByTab[sidebarActiveTabId] = frag;
|
|
chatLineCountByTab[sidebarActiveTabId] = chatLineCount;
|
|
}
|
|
|
|
sidebarActiveTabId = newTabId;
|
|
|
|
// Restore saved chat for new tab, or carry over current DOM if we're
|
|
// mid-message (the server may have switched tabs because the user's
|
|
// Chrome tab changed, but we still want to show the optimistic UI).
|
|
if (chatDomByTab[newTabId]) {
|
|
while (chatMessages.firstChild) chatMessages.removeChild(chatMessages.firstChild);
|
|
chatMessages.appendChild(chatDomByTab[newTabId]);
|
|
chatLineCount = chatLineCountByTab[newTabId] || 0;
|
|
// Reset agent state for restored tab
|
|
agentContainer = null;
|
|
agentTextEl = null;
|
|
agentText = '';
|
|
} else if (lastOptimisticMsg && document.getElementById('agent-thinking')) {
|
|
// We're mid-send with optimistic UI — keep it, don't blow it away.
|
|
// The poll for the new tab will pick up the entries and sync naturally.
|
|
chatLineCount = 0;
|
|
// agentContainer/agentTextEl are already set from sendMessage()
|
|
} else {
|
|
while (chatMessages.firstChild) chatMessages.removeChild(chatMessages.firstChild);
|
|
const welcomeDiv = document.createElement('div');
|
|
welcomeDiv.className = 'chat-welcome';
|
|
welcomeDiv.id = 'chat-welcome';
|
|
const iconDiv = document.createElement('div');
|
|
iconDiv.className = 'chat-welcome-icon';
|
|
iconDiv.textContent = 'G';
|
|
welcomeDiv.appendChild(iconDiv);
|
|
const p1 = document.createElement('p');
|
|
p1.textContent = 'Send a message about this page.';
|
|
welcomeDiv.appendChild(p1);
|
|
const p2 = document.createElement('p');
|
|
p2.className = 'muted';
|
|
p2.textContent = 'Each tab has its own conversation.';
|
|
welcomeDiv.appendChild(p2);
|
|
chatMessages.appendChild(welcomeDiv);
|
|
chatLineCount = 0;
|
|
// Reset agent state for fresh tab
|
|
agentContainer = null;
|
|
agentTextEl = null;
|
|
agentText = '';
|
|
}
|
|
|
|
// Immediately poll the new tab's chat
|
|
setTimeout(pollChat, 0);
|
|
}
|
|
|
|
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 {
|
|
const resp = await fetch(`${serverUrl}/sidebar-agent/stop`, { method: 'POST', headers: authHeaders() });
|
|
if (!resp.ok) console.warn(`[gstack sidebar] Stop agent failed: ${resp.status}`);
|
|
} catch (err) {
|
|
console.error('[gstack sidebar] Stop agent error:', err.message);
|
|
}
|
|
// 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 (err) {
|
|
console.debug('[gstack sidebar] Failed to get active tab URL:', err.message);
|
|
}
|
|
|
|
const resp = await fetch(`${serverUrl}/sidebar-tabs${activeTabUrl ? '?activeUrl=' + encodeURIComponent(activeTabUrl) : ''}`, {
|
|
headers: authHeaders(),
|
|
signal: AbortSignal.timeout(2000),
|
|
});
|
|
if (!resp.ok) {
|
|
console.warn(`[gstack sidebar] Tab poll failed: ${resp.status} ${resp.statusText}`);
|
|
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 (err) {
|
|
console.error('[gstack sidebar] Tab poll error:', err.message);
|
|
}
|
|
}
|
|
|
|
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 (err) {
|
|
console.error('[gstack sidebar] Failed to switch browser tab:', err.message);
|
|
}
|
|
}
|
|
|
|
// ─── Clear Chat ─────────────────────────────────────────────────
|
|
|
|
document.getElementById('clear-chat').addEventListener('click', async () => {
|
|
if (!serverUrl) return;
|
|
try {
|
|
const resp = await fetch(`${serverUrl}/sidebar-chat/clear`, { method: 'POST', headers: authHeaders() });
|
|
if (!resp.ok) console.warn(`[gstack sidebar] Clear chat failed: ${resp.status}`);
|
|
} catch (err) {
|
|
console.error('[gstack sidebar] Clear chat error:', err.message);
|
|
}
|
|
// Reset local state
|
|
chatLineCount = 0;
|
|
renderedEntryIds.clear();
|
|
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>`;
|
|
});
|
|
|
|
// ─── Reload Sidebar ─────────────────────────────────────────────
|
|
document.getElementById('reload-sidebar').addEventListener('click', () => {
|
|
location.reload();
|
|
});
|
|
|
|
// ─── Copy Cookies ───────────────────────────────────────────────
|
|
document.getElementById('chat-cookies-btn').addEventListener('click', async () => {
|
|
if (!serverUrl) return;
|
|
// Navigate the browser to the cookie picker page hosted by the browse server
|
|
try {
|
|
await fetch(`${serverUrl}/command`, {
|
|
method: 'POST',
|
|
headers: authHeaders(),
|
|
body: JSON.stringify({ command: 'goto', args: [`${serverUrl}/cookie-picker`] }),
|
|
});
|
|
} catch (err) {
|
|
console.error('[gstack sidebar] Failed to open cookie picker:', err.message);
|
|
}
|
|
});
|
|
|
|
// ─── 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 (err) {
|
|
console.error('[gstack sidebar] Failed to parse activity event:', err.message);
|
|
}
|
|
});
|
|
|
|
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 (err) {
|
|
console.error('[gstack sidebar] Failed to parse gap event:', err.message);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ─── 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 (err) {
|
|
console.error('[gstack sidebar] Failed to fetch refs:', err.message);
|
|
}
|
|
}
|
|
|
|
// ─── 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">▶</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 ? '▼' : '▶';
|
|
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 });
|
|
});
|
|
|
|
// ─── Quick Action Helpers (shared between chat toolbar + inspector) ──
|
|
|
|
async function runCleanup(...buttons) {
|
|
if (!serverUrl || !serverToken) {
|
|
return;
|
|
}
|
|
buttons.forEach(b => b?.classList.add('loading'));
|
|
|
|
// Smart cleanup: send a chat message to the sidebar agent (an LLM).
|
|
// The agent snapshots the page, understands it semantically, and removes
|
|
// clutter intelligently. Much better than brittle CSS selectors.
|
|
const cleanupPrompt = [
|
|
'Clean up this page for reading. First run a quick deterministic pass:',
|
|
'$B cleanup --all',
|
|
'',
|
|
'Then take a snapshot to see what\'s left:',
|
|
'$B snapshot -i',
|
|
'',
|
|
'Look at the snapshot and identify remaining non-content elements:',
|
|
'- Ad placeholders, "ADVERTISEMENT" labels, sponsored content',
|
|
'- Cookie/consent banners, newsletter popups, login walls',
|
|
'- Audio/podcast player widgets, video autoplay',
|
|
'- Sidebar widgets (puzzles, games, "most popular", recommendations)',
|
|
'- Social share buttons, follow prompts, "See more on Google"',
|
|
'- Floating chat widgets, feedback buttons',
|
|
'- Navigation drawers, mega-menus (unless they ARE the page content)',
|
|
'- Empty whitespace from removed ads',
|
|
'',
|
|
'KEEP: the site header/masthead/logo, article headline, article body,',
|
|
'article images, author byline, date. The page should still look like',
|
|
'the site it is, just without the crap.',
|
|
'',
|
|
'For each element to remove, run JavaScript via $B to hide it:',
|
|
'$B eval "document.querySelector(\'SELECTOR\').style.display=\'none\'"',
|
|
'',
|
|
'Also unlock scrolling if the page is scroll-locked:',
|
|
'$B eval "document.body.style.overflow=\'auto\';document.documentElement.style.overflow=\'auto\'"',
|
|
].join('\n');
|
|
|
|
try {
|
|
// Send as a sidebar command (spawns the agent)
|
|
const resp = await fetch(`${serverUrl}/sidebar-command`, {
|
|
method: 'POST',
|
|
headers: authHeaders(),
|
|
body: JSON.stringify({ message: cleanupPrompt }),
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
if (resp.ok) {
|
|
addChatEntry({ type: 'notification', message: 'Cleaning up page (agent is analyzing...)' });
|
|
} else {
|
|
addChatEntry({ type: 'notification', message: 'Failed to start cleanup' });
|
|
}
|
|
} catch (err) {
|
|
addChatEntry({ type: 'notification', message: 'Cleanup failed: ' + err.message });
|
|
} finally {
|
|
// Remove loading after a short delay (agent runs async)
|
|
setTimeout(() => buttons.forEach(b => b?.classList.remove('loading')), 2000);
|
|
}
|
|
}
|
|
|
|
async function runScreenshot(...buttons) {
|
|
if (!serverUrl || !serverToken) {
|
|
return;
|
|
}
|
|
buttons.forEach(b => b?.classList.add('loading'));
|
|
try {
|
|
const resp = await fetch(`${serverUrl}/command`, {
|
|
method: 'POST',
|
|
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ command: 'screenshot', args: [] }),
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
const text = await resp.text();
|
|
if (resp.ok) {
|
|
addChatEntry({ type: 'notification', message: text || 'Screenshot saved' });
|
|
} else {
|
|
const err = JSON.parse(text).error || 'Screenshot failed';
|
|
addChatEntry({ type: 'notification', message: 'Error: ' + err });
|
|
}
|
|
} catch (err) {
|
|
addChatEntry({ type: 'notification', message: 'Screenshot failed: ' + err.message });
|
|
} finally {
|
|
buttons.forEach(b => b?.classList.remove('loading'));
|
|
}
|
|
}
|
|
|
|
// ─── Wire up all cleanup/screenshot buttons (inspector + chat toolbar) ──
|
|
|
|
const inspectorCleanupBtn = document.getElementById('inspector-cleanup-btn');
|
|
const inspectorScreenshotBtn = document.getElementById('inspector-screenshot-btn');
|
|
const chatCleanupBtn = document.getElementById('chat-cleanup-btn');
|
|
const chatScreenshotBtn = document.getElementById('chat-screenshot-btn');
|
|
|
|
if (inspectorCleanupBtn) inspectorCleanupBtn.addEventListener('click', () => runCleanup(inspectorCleanupBtn, chatCleanupBtn));
|
|
if (inspectorScreenshotBtn) inspectorScreenshotBtn.addEventListener('click', () => runScreenshot(inspectorScreenshotBtn, chatScreenshotBtn));
|
|
if (chatCleanupBtn) chatCleanupBtn.addEventListener('click', () => runCleanup(chatCleanupBtn, inspectorCleanupBtn));
|
|
if (chatScreenshotBtn) chatScreenshotBtn.addEventListener('click', () => runScreenshot(chatScreenshotBtn, inspectorScreenshotBtn));
|
|
|
|
// ─── 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 (err) {
|
|
console.error('[gstack sidebar] Failed to parse inspectResult:', err.message);
|
|
}
|
|
});
|
|
|
|
inspectorSSE.addEventListener('error', () => {
|
|
// SSE connection failed — inspector works without it (basic mode)
|
|
if (inspectorSSE) { inspectorSSE.close(); inspectorSSE = null; }
|
|
});
|
|
} catch (err) {
|
|
console.debug('[gstack sidebar] Inspector SSE not available:', err.message);
|
|
}
|
|
}
|
|
|
|
// ─── Server Discovery ───────────────────────────────────────────
|
|
|
|
function setActionButtonsEnabled(enabled) {
|
|
const btns = document.querySelectorAll('.quick-action-btn, .inspector-action-btn');
|
|
btns.forEach(btn => {
|
|
btn.disabled = !enabled;
|
|
btn.classList.toggle('disabled', !enabled);
|
|
});
|
|
}
|
|
|
|
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');
|
|
setActionButtonsEnabled(true);
|
|
// Tell the active tab's content script the sidebar is open — this hides
|
|
// the welcome page arrow hint. Only fires on actual sidebar connection.
|
|
chrome.runtime.sendMessage({ type: 'sidebarOpened' }).catch(() => {});
|
|
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 = '';
|
|
setActionButtonsEnabled(false);
|
|
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('/open-gstack-browser').then(() => {
|
|
const btn = document.getElementById('conn-copy');
|
|
btn.textContent = 'copied!';
|
|
setTimeout(() => { btn.textContent = '/open-gstack-browser'; }, 2000);
|
|
});
|
|
});
|
|
|
|
// Try to connect immediately, retry every 2s until connected.
|
|
// Show exactly what's happening at each step so the user is never
|
|
// staring at a blank "Connecting..." with no info.
|
|
let connectAttempts = 0;
|
|
function setLoadingStatus(msg, debug) {
|
|
const status = document.getElementById('loading-status');
|
|
const dbg = document.getElementById('loading-debug');
|
|
if (status) status.textContent = msg;
|
|
if (dbg && debug !== undefined) dbg.textContent = debug;
|
|
}
|
|
|
|
async function tryConnect() {
|
|
connectAttempts++;
|
|
setLoadingStatus(
|
|
`Looking for browse server... (attempt ${connectAttempts})`,
|
|
`Asking background.js for server port...`
|
|
);
|
|
|
|
// Step 1: Ask background for the port
|
|
const resp = await new Promise(resolve => {
|
|
chrome.runtime.sendMessage({ type: 'getPort' }, (r) => {
|
|
if (chrome.runtime.lastError) {
|
|
resolve({ error: chrome.runtime.lastError.message });
|
|
} else {
|
|
resolve(r || {});
|
|
}
|
|
});
|
|
});
|
|
|
|
if (resp.error) {
|
|
setLoadingStatus(
|
|
`Extension error (attempt ${connectAttempts})`,
|
|
`chrome.runtime.sendMessage failed:\n${resp.error}`
|
|
);
|
|
setTimeout(tryConnect, 2000);
|
|
return;
|
|
}
|
|
|
|
const port = resp.port || 34567;
|
|
|
|
// Step 2: If background says connected + has token, use that
|
|
if (resp.port && resp.connected && resp.token) {
|
|
setLoadingStatus(
|
|
`Server found on port ${port}, connecting...`,
|
|
`token: yes\nStarting SSE + chat polling...`
|
|
);
|
|
updateConnection(`http://127.0.0.1:${port}`, resp.token);
|
|
return;
|
|
}
|
|
|
|
// Step 3: Background not connected yet. Try hitting /health directly.
|
|
// This bypasses the background.js health poll timing gap.
|
|
setLoadingStatus(
|
|
`Checking server directly... (attempt ${connectAttempts})`,
|
|
`port: ${port}\nbackground connected: ${resp.connected || false}\nTrying GET http://127.0.0.1:${port}/health ...`
|
|
);
|
|
|
|
try {
|
|
const healthResp = await fetch(`http://127.0.0.1:${port}/health`, {
|
|
signal: AbortSignal.timeout(2000)
|
|
});
|
|
if (healthResp.ok) {
|
|
const data = await healthResp.json();
|
|
if (data.status === 'healthy' && data.token) {
|
|
setLoadingStatus(
|
|
`Server healthy on port ${port}, connecting...`,
|
|
`token: yes (from /health)\nStarting SSE + chat polling...`
|
|
);
|
|
updateConnection(`http://127.0.0.1:${port}`, data.token);
|
|
return;
|
|
}
|
|
setLoadingStatus(
|
|
`Server responded but not healthy (attempt ${connectAttempts})`,
|
|
`status: ${data.status}\ntoken: ${data.token ? 'yes' : 'no'}`
|
|
);
|
|
} else {
|
|
setLoadingStatus(
|
|
`Server returned ${healthResp.status} (attempt ${connectAttempts})`,
|
|
`GET /health → ${healthResp.status} ${healthResp.statusText}`
|
|
);
|
|
}
|
|
} catch (e) {
|
|
setLoadingStatus(
|
|
`Server not reachable on port ${port} (attempt ${connectAttempts})`,
|
|
`GET /health failed: ${e.message}\n\nThe browse server may still be starting.\nRun /open-gstack-browser in Claude Code.`
|
|
);
|
|
}
|
|
|
|
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}`;
|
|
// Request token via targeted sendResponse (not broadcast) to limit exposure
|
|
chrome.runtime.sendMessage({ type: 'getToken' }, (resp) => {
|
|
updateConnection(url, resp?.token || null);
|
|
});
|
|
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');
|
|
}
|
|
}
|
|
}
|