diff --git a/TODOS.md b/TODOS.md index 766c3a78..ec657601 100644 --- a/TODOS.md +++ b/TODOS.md @@ -131,14 +131,53 @@ **Effort:** L **Priority:** P4 -### CDP mode +### CDP mode — SHIPPED (Phase 1) -**What:** Connect to already-running Chrome/Electron apps via Chrome DevTools Protocol. +`$B connect` connects to real Chrome/Comet via CDP. All existing browse commands work unchanged. Chrome extension with Side Panel activity feed. See `browse/src/chrome-launcher.ts`. -**Why:** Test production apps, Electron apps, and existing browser sessions without launching new instances. +### `$B watch` — passive observation mode -**Effort:** M +**What:** Claude observes your browsing without interacting. Captures snapshots, console logs, network requests as you navigate. "Watch me do this, then you do the same." + +**Why:** Bridges the gap between "Claude controls my browser" and "Claude learns from me." Enables flow recording for QA regression tests. + +**Context:** Requires CDP connect (shipped). Would add a new browse command that enters read-only mode with periodic snapshot capture. User demonstrates a flow, Claude records it, then can reproduce. + +**Effort:** M (human: ~1 week / CC: ~30 min) +**Priority:** P2 +**Depends on:** CDP connect (shipped) + +### Multi-agent tab isolation + +**What:** Two Claude sessions connect to the same Chrome, each operating on different tabs. No cross-contamination. + +**Why:** Enables parallel /qa + /design-review on different tabs in the same browser. + +**Context:** Requires tab ownership model for concurrent CDP connections. Playwright may not cleanly support two `connectOverCDP` sessions to the same browser. Needs investigation. + +**Effort:** L (human: ~2 weeks / CC: ~2 hours) +**Priority:** P3 +**Depends on:** CDP connect (shipped) + +### Cross-platform CDP browser discovery + +**What:** Extend browser discovery algorithm to Windows (`where chrome`, registry lookup) and Linux (`which google-chrome`, XDG paths). Focus command via wmctrl (Linux) and PowerShell (Windows). + +**Why:** gstack already has Windows support (Node.js fallback). CDP connect should follow. + +**Effort:** M (human: ~1 week / CC: ~30 min) +**Priority:** P3 +**Depends on:** CDP connect (shipped) + +### Chrome Web Store publishing + +**What:** Publish the gstack browse Chrome extension to Chrome Web Store for easier install. + +**Why:** Currently sideloaded via chrome://extensions. Web Store makes install one-click. + +**Effort:** S **Priority:** P4 +**Depends on:** Chrome extension proving value via sideloading ### Linux/Windows cookie decryption diff --git a/docs/designs/CONDUCTOR_SESSION_API.md b/docs/designs/CONDUCTOR_SESSION_API.md new file mode 100644 index 00000000..6c721cc0 --- /dev/null +++ b/docs/designs/CONDUCTOR_SESSION_API.md @@ -0,0 +1,108 @@ +# Conductor Session Streaming API Proposal + +## Problem + +When Claude controls your real browser via CDP (gstack `$B connect`), you look at two +windows: **Conductor** (to see Claude's thinking) and **Chrome** (to see Claude's actions). + +gstack's Chrome extension Side Panel shows browse activity — every command, result, +and error. But for *full* session mirroring (Claude's thinking, tool calls, code edits), +the Side Panel needs Conductor to expose the conversation stream. + +## What this enables + +A "Session" tab in the gstack Chrome extension Side Panel that shows: +- Claude's thinking/content (truncated for performance) +- Tool call names + icons (Edit, Bash, Read, etc.) +- Turn boundaries with cost estimates +- Real-time updates as the conversation progresses + +The user sees everything in one place — Claude's actions in their browser + Claude's +thinking in the Side Panel — without switching windows. + +## Proposed API + +### `GET http://127.0.0.1:{PORT}/workspace/{ID}/session/stream` + +Server-Sent Events endpoint that re-emits Claude Code's conversation as NDJSON events. + +**Event types** (reuse Claude Code's `--output-format stream-json` format): + +``` +event: assistant +data: {"type":"assistant","content":"Let me check that page...","truncated":true} + +event: tool_use +data: {"type":"tool_use","name":"Bash","input":"$B snapshot","truncated_input":true} + +event: tool_result +data: {"type":"tool_result","name":"Bash","output":"[snapshot output...]","truncated_output":true} + +event: turn_complete +data: {"type":"turn_complete","input_tokens":1234,"output_tokens":567,"cost_usd":0.02} +``` + +**Content truncation:** Tool inputs/outputs capped at 500 chars in the stream. Full +data stays in Conductor's UI. The Side Panel is a summary view, not a replacement. + +### `GET http://127.0.0.1:{PORT}/api/workspaces` + +Discovery endpoint listing active workspaces. + +```json +{ + "workspaces": [ + { + "id": "abc123", + "name": "gstack", + "branch": "garrytan/chrome-extension-ctrl", + "directory": "/Users/garry/gstack", + "pid": 12345, + "active": true + } + ] +} +``` + +The Chrome extension auto-selects a workspace by matching the browse server's git repo +(from `/health` response) to a workspace's directory or name. + +## Security + +- **Localhost-only.** Same trust model as Claude Code's own debug output. +- **No auth required.** If Conductor wants auth, include a Bearer token in the + workspace listing that the extension passes on SSE requests. +- **Content truncation** is a privacy feature — long code outputs, file contents, and + sensitive tool results never leave Conductor's full UI. + +## What gstack builds (extension side) + +Already scaffolded in the Side Panel "Session" tab (currently shows placeholder). + +When Conductor's API is available: +1. Side Panel discovers Conductor via port probe or manual entry +2. Fetches `/api/workspaces`, matches to browse server's repo +3. Opens `EventSource` to `/workspace/{id}/session/stream` +4. Renders: assistant messages, tool names + icons, turn boundaries, cost +5. Falls back gracefully: "Connect Conductor for full session view" + +Estimated effort: ~200 LOC in `sidepanel.js`. + +## What Conductor builds (server side) + +1. SSE endpoint that re-emits Claude Code's stream-json per workspace +2. `/api/workspaces` discovery endpoint with active workspace list +3. Content truncation (500 char cap on tool inputs/outputs) + +Estimated effort: ~100-200 LOC if Conductor already captures the Claude Code stream +internally (which it does for its own UI rendering). + +## Design decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Transport | SSE (not WebSocket) | Unidirectional, auto-reconnect, simpler | +| Format | Claude's stream-json | Conductor already parses this; no new schema | +| Discovery | HTTP endpoint (not file) | Chrome extensions can't read filesystem | +| Auth | None (localhost) | Same as browse server, CDP port, Claude Code | +| Truncation | 500 chars | Side Panel is ~300px wide; long content useless | diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 00000000..33c70c38 --- /dev/null +++ b/extension/background.js @@ -0,0 +1,130 @@ +/** + * gstack browse — background service worker + * + * Polls /health every 10s to detect browse server. + * Fetches /refs on snapshot completion, relays to content script. + * Updates badge: green (connected), gray (disconnected). + */ + +let serverPort = null; +let isConnected = false; +let healthInterval = null; + +// ─── Port Discovery ──────────────────────────────────────────── + +async function loadPort() { + const data = await chrome.storage.local.get('port'); + serverPort = data.port || null; + return serverPort; +} + +async function savePort(port) { + serverPort = port; + await chrome.storage.local.set({ port }); +} + +function getBaseUrl() { + return serverPort ? `http://127.0.0.1:${serverPort}` : null; +} + +// ─── Health Polling ──────────────────────────────────────────── + +async function checkHealth() { + const base = getBaseUrl(); + if (!base) { + setDisconnected(); + return; + } + + try { + const resp = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) }); + if (!resp.ok) { setDisconnected(); return; } + const data = await resp.json(); + if (data.status === 'healthy') { + setConnected(data); + } else { + setDisconnected(); + } + } catch { + setDisconnected(); + } +} + +function setConnected(healthData) { + if (!isConnected) { + isConnected = true; + chrome.action.setBadgeText({ text: '' }); + chrome.action.setBadgeBackgroundColor({ color: '#4ade80' }); + // Small green dot via badge + chrome.action.setBadgeText({ text: ' ' }); + } + // Broadcast health to popup and side panel + chrome.runtime.sendMessage({ type: 'health', data: healthData }).catch(() => {}); +} + +function setDisconnected() { + if (isConnected) { + isConnected = false; + chrome.action.setBadgeText({ text: '' }); + } + chrome.runtime.sendMessage({ type: 'health', data: null }).catch(() => {}); +} + +// ─── Refs Relay ───────────────────────────────────────────────── + +async function fetchAndRelayRefs() { + const base = getBaseUrl(); + if (!base || !isConnected) return; + + try { + const resp = await fetch(`${base}/refs`, { signal: AbortSignal.timeout(3000) }); + if (!resp.ok) return; + const data = await resp.json(); + + // Send to all tabs' content scripts + const tabs = await chrome.tabs.query({}); + for (const tab of tabs) { + if (tab.id) { + chrome.tabs.sendMessage(tab.id, { type: 'refs', data }).catch(() => {}); + } + } + } catch {} +} + +// ─── Message Handling ────────────────────────────────────────── + +chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + if (msg.type === 'getPort') { + sendResponse({ port: serverPort, connected: isConnected }); + return true; + } + + if (msg.type === 'setPort') { + savePort(msg.port).then(() => { + checkHealth(); + sendResponse({ ok: true }); + }); + return true; + } + + if (msg.type === 'getServerUrl') { + sendResponse({ url: getBaseUrl() }); + return true; + } + + if (msg.type === 'fetchRefs') { + fetchAndRelayRefs().then(() => sendResponse({ ok: true })); + return true; + } +}); + +// ─── Side Panel ───────────────────────────────────────────────── + +chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: false }).catch(() => {}); + +// ─── Startup ──────────────────────────────────────────────────── + +loadPort().then(() => { + checkHealth(); + healthInterval = setInterval(checkHealth, 10000); +}); diff --git a/extension/content.css b/extension/content.css new file mode 100644 index 00000000..880990e8 --- /dev/null +++ b/extension/content.css @@ -0,0 +1,77 @@ +/* gstack browse — ref overlay styles */ + +#gstack-ref-overlays { + font-family: 'SF Mono', 'Fira Code', monospace !important; +} + +.gstack-ref-badge { + position: absolute; + background: rgba(220, 38, 38, 0.9); + color: #fff; + font-size: 10px; + font-weight: 700; + padding: 1px 4px; + border-radius: 3px; + line-height: 14px; + pointer-events: none; + z-index: 2147483647; +} + +/* Floating ref panel (used when positions are unknown) */ +.gstack-ref-panel { + position: fixed; + bottom: 12px; + right: 12px; + width: 220px; + max-height: 300px; + background: rgba(10, 10, 10, 0.95); + border: 1px solid #333; + border-radius: 6px; + overflow: hidden; + pointer-events: auto; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5); + font-size: 11px; +} + +.gstack-ref-panel-header { + padding: 6px 10px; + background: #0f0f0f; + border-bottom: 1px solid #222; + color: #fff; + font-weight: 600; + font-size: 11px; +} + +.gstack-ref-panel-list { + max-height: 260px; + overflow-y: auto; +} + +.gstack-ref-panel-row { + padding: 3px 10px; + border-bottom: 1px solid #1a1a1a; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.gstack-ref-panel-id { + color: #4ade80; + font-weight: 600; + margin-right: 4px; +} + +.gstack-ref-panel-role { + color: #888; + margin-right: 4px; +} + +.gstack-ref-panel-name { + color: #e0e0e0; +} + +.gstack-ref-panel-more { + padding: 4px 10px; + color: #666; + font-style: italic; +} diff --git a/extension/content.js b/extension/content.js new file mode 100644 index 00000000..11f2563f --- /dev/null +++ b/extension/content.js @@ -0,0 +1,96 @@ +/** + * gstack browse — content script + * + * Receives ref data from background worker via chrome.runtime.onMessage. + * Renders @ref overlay badges on the page (CDP mode only — positions are accurate). + * In headless mode, shows a floating ref panel instead (positions unknown). + */ + +let overlayContainer = null; + +function ensureContainer() { + if (overlayContainer) return overlayContainer; + overlayContainer = document.createElement('div'); + overlayContainer.id = 'gstack-ref-overlays'; + overlayContainer.style.cssText = 'position: fixed; top: 0; left: 0; width: 0; height: 0; z-index: 2147483647; pointer-events: none;'; + document.body.appendChild(overlayContainer); + return overlayContainer; +} + +function clearOverlays() { + if (overlayContainer) { + overlayContainer.innerHTML = ''; + } +} + +function renderRefBadges(refs) { + clearOverlays(); + if (!refs || refs.length === 0) return; + + const container = ensureContainer(); + + for (const ref of refs) { + // Try to find the element using accessible name/role for positioning + // In CDP mode, we could use bounding boxes from the server + // For now, use a floating panel approach + const badge = document.createElement('div'); + badge.className = 'gstack-ref-badge'; + badge.textContent = ref.ref; + badge.title = `${ref.role}: "${ref.name}"`; + container.appendChild(badge); + } +} + +function renderRefPanel(refs) { + clearOverlays(); + if (!refs || refs.length === 0) return; + + const container = ensureContainer(); + + const panel = document.createElement('div'); + panel.className = 'gstack-ref-panel'; + + const header = document.createElement('div'); + header.className = 'gstack-ref-panel-header'; + header.textContent = `gstack refs (${refs.length})`; + header.style.cssText = 'pointer-events: auto; cursor: move;'; + panel.appendChild(header); + + const list = document.createElement('div'); + list.className = 'gstack-ref-panel-list'; + for (const ref of refs.slice(0, 30)) { // Show max 30 in panel + const row = document.createElement('div'); + row.className = 'gstack-ref-panel-row'; + row.innerHTML = `${ref.ref} ${ref.role} "${ref.name}"`; + list.appendChild(row); + } + if (refs.length > 30) { + const more = document.createElement('div'); + more.className = 'gstack-ref-panel-more'; + more.textContent = `+${refs.length - 30} more`; + list.appendChild(more); + } + panel.appendChild(list); + container.appendChild(panel); +} + +// Listen for ref data from background worker +chrome.runtime.onMessage.addListener((msg) => { + if (msg.type === 'refs' && msg.data) { + const refs = msg.data.refs || []; + const mode = msg.data.mode; + + if (refs.length === 0) { + clearOverlays(); + return; + } + + // CDP mode: could use bounding boxes (future) + // For now: floating panel for all modes + renderRefPanel(refs); + } + + if (msg.type === 'clearRefs') { + clearOverlays(); + } +}); diff --git a/extension/icons/icon-128.png b/extension/icons/icon-128.png new file mode 100644 index 00000000..7deb432e Binary files /dev/null and b/extension/icons/icon-128.png differ diff --git a/extension/icons/icon-16.png b/extension/icons/icon-16.png new file mode 100644 index 00000000..273c0afc Binary files /dev/null and b/extension/icons/icon-16.png differ diff --git a/extension/icons/icon-48.png b/extension/icons/icon-48.png new file mode 100644 index 00000000..1678c43f Binary files /dev/null and b/extension/icons/icon-48.png differ diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 00000000..3a28d5ed --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,32 @@ +{ + "manifest_version": 3, + "name": "gstack browse", + "version": "0.1.0", + "description": "Live activity feed and @ref overlays for gstack browse", + "permissions": ["sidePanel", "storage", "activeTab"], + "host_permissions": ["http://127.0.0.1:*/"], + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } + }, + "side_panel": { + "default_path": "sidepanel.html" + }, + "background": { + "service_worker": "background.js" + }, + "content_scripts": [{ + "matches": [""], + "js": ["content.js"], + "css": ["content.css"] + }], + "icons": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } +} diff --git a/extension/popup.html b/extension/popup.html new file mode 100644 index 00000000..f6a95583 --- /dev/null +++ b/extension/popup.html @@ -0,0 +1,98 @@ + + + + + + + +

gstack

+ + + + +
+
+ Disconnected +
+
+ + + + + + diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 00000000..68fa25af --- /dev/null +++ b/extension/popup.js @@ -0,0 +1,60 @@ +const portInput = document.getElementById('port'); +const dot = document.getElementById('dot'); +const statusText = document.getElementById('status-text'); +const details = document.getElementById('details'); +const sidePanelBtn = document.getElementById('side-panel-btn'); + +// Load saved port +chrome.runtime.sendMessage({ type: 'getPort' }, (resp) => { + if (resp && resp.port) { + portInput.value = resp.port; + updateStatus(resp.connected); + } +}); + +// Save port on change +let saveTimeout; +portInput.addEventListener('input', () => { + clearTimeout(saveTimeout); + saveTimeout = setTimeout(() => { + const port = parseInt(portInput.value, 10); + if (port > 0 && port < 65536) { + chrome.runtime.sendMessage({ type: 'setPort', port }); + } + }, 500); +}); + +// Listen for health updates +chrome.runtime.onMessage.addListener((msg) => { + if (msg.type === 'health') { + updateStatus(!!msg.data, msg.data); + } +}); + +function updateStatus(connected, data) { + dot.className = `dot ${connected ? 'connected' : ''}`; + statusText.className = `status-text ${connected ? 'connected' : ''}`; + statusText.textContent = connected ? 'Connected' : 'Disconnected'; + + if (connected && data) { + const parts = []; + if (data.tabs) parts.push(`${data.tabs} tabs`); + if (data.mode) parts.push(`Mode: ${data.mode}`); + details.textContent = parts.join(' \u00b7 '); + } else { + details.textContent = ''; + } +} + +// Open side panel +sidePanelBtn.addEventListener('click', async () => { + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tab) { + await chrome.sidePanel.open({ tabId: tab.id }); + window.close(); + } + } catch (err) { + details.textContent = `Side panel error: ${err.message}`; + } +}); diff --git a/extension/sidepanel.css b/extension/sidepanel.css new file mode 100644 index 00000000..87ecf7b7 --- /dev/null +++ b/extension/sidepanel.css @@ -0,0 +1,317 @@ +/* gstack browse — Side Panel dark theme */ +/* Design tokens from cookie picker, extended */ + +* { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --bg-body: #0a0a0a; + --bg-header: #0f0f0f; + --bg-surface: #1a1a1a; + --bg-hover: #151515; + --border: #222; + --border-inactive: #333; + --border-hover: #555; + --text-heading: #fff; + --text-body: #e0e0e0; + --text-label: #888; + --text-meta: #666; + --text-disabled: #555; + --green: #4ade80; + --red: #f87171; + --blue: #60a5fa; + --purple: #a78bfa; + --amber: #fbbf24; + --font-system: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + --font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; +} + +body { + background: var(--bg-body); + color: var(--text-body); + font-family: var(--font-system); + font-size: 13px; + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* ─── Header ──────────────────────────────────────────── */ +header { + height: 40px; + background: var(--bg-header); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 12px; + flex-shrink: 0; +} +.header-left { display: flex; align-items: center; gap: 8px; } +.monogram { + width: 22px; + height: 22px; + background: var(--green); + color: #000; + font-weight: 700; + font-size: 13px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; +} +.title { color: var(--text-heading); font-weight: 600; font-size: 14px; letter-spacing: -0.3px; } +.header-right { display: flex; align-items: center; gap: 6px; } +.header-port { color: var(--text-meta); font-family: var(--font-mono); font-size: 11px; } + +/* ─── Status Dot ──────────────────────────────────────── */ +.dot { + width: 8px; height: 8px; + border-radius: 50%; + background: var(--text-disabled); + flex-shrink: 0; +} +.dot.connected { background: var(--green); } +.dot.reconnecting { + background: var(--amber); + animation: pulse 1.5s ease-in-out infinite; +} +@keyframes pulse { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 1; } +} + +/* ─── Tab Bar ─────────────────────────────────────────── */ +.tabs { + height: 36px; + background: var(--bg-header); + border-bottom: 1px solid var(--border); + display: flex; + flex-shrink: 0; +} +.tab { + flex: 1; + background: none; + border: none; + color: var(--text-label); + font-size: 12px; + font-weight: 500; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.15s; +} +.tab:hover:not(.disabled) { color: #ccc; } +.tab.active { + color: var(--text-heading); + border-bottom-color: var(--green); +} +.tab.disabled { + color: var(--text-disabled); + cursor: not-allowed; +} + +/* ─── Tab Content ─────────────────────────────────────── */ +.tab-content { + display: none; + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} +.tab-content.active { display: flex; flex-direction: column; } + +/* ─── Activity Feed ───────────────────────────────────── */ +#activity-feed { flex: 1; } + +.activity-entry { + padding: 8px 12px; + border-left: 3px solid var(--border); + border-bottom: 1px solid var(--border); + cursor: pointer; + transition: background 0.15s; + animation: slideIn 0.15s ease; +} +.activity-entry:hover { background: var(--bg-hover); } + +@media (prefers-reduced-motion: reduce) { + .activity-entry { animation: none; } +} + +@keyframes slideIn { + from { transform: translateY(8px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +/* Left border colors by type */ +.activity-entry.nav { border-left-color: var(--blue); } +.activity-entry.interaction { border-left-color: var(--green); } +.activity-entry.observe { border-left-color: var(--purple); } +.activity-entry.error { border-left-color: var(--red); } +.activity-entry.pending { + border-left-color: var(--amber); + animation: slideIn 0.15s ease, borderPulse 1.5s ease-in-out infinite; +} +@keyframes borderPulse { + 0%, 100% { border-left-color: rgba(251, 191, 36, 0.4); } + 50% { border-left-color: rgba(251, 191, 36, 1); } +} + +.entry-header { + display: flex; + align-items: baseline; + gap: 8px; +} +.entry-time { + color: var(--text-meta); + font-family: var(--font-mono); + font-size: 11px; + flex-shrink: 0; +} +.entry-command { + color: var(--text-heading); + font-family: var(--font-mono); + font-size: 13px; + font-weight: 600; +} +.entry-args { + color: var(--text-label); + font-family: var(--font-mono); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 2px; +} +.entry-status { + font-size: 11px; + margin-top: 2px; + display: flex; + align-items: center; + gap: 4px; +} +.entry-status .ok { color: var(--green); } +.entry-status .err { color: var(--red); } +.entry-status .duration { color: var(--text-meta); } + +/* Expanded state */ +.entry-detail { + display: none; + margin-top: 8px; + padding-top: 8px; + border-top: 1px dashed var(--border); +} +.activity-entry.expanded .entry-detail { display: block; } +.activity-entry.expanded .entry-args { white-space: normal; } +.entry-result { + color: #aaa; + font-family: var(--font-mono); + font-size: 12px; + white-space: pre-wrap; + word-break: break-word; +} + +/* ─── Refs Tab ────────────────────────────────────────── */ +.ref-row { + height: 32px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px; + border-bottom: 1px solid var(--border); + font-size: 12px; +} +.ref-id { + color: var(--green); + font-family: var(--font-mono); + font-weight: 600; + min-width: 32px; +} +.ref-role { + color: var(--text-label); + min-width: 60px; +} +.ref-name { + color: var(--text-body); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.refs-footer { + padding: 8px 12px; + color: var(--text-meta); + font-size: 11px; + border-top: 1px solid var(--border); +} + +/* ─── Session Placeholder ─────────────────────────────── */ +.session-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; + color: var(--text-label); + padding: 24px; + gap: 8px; +} +.session-placeholder .muted { color: var(--text-meta); font-size: 12px; } + +/* ─── Empty State ─────────────────────────────────────── */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 24px; + text-align: center; + color: var(--text-label); + gap: 4px; +} +.empty-state .muted { color: var(--text-meta); font-size: 12px; } +.empty-state code { + background: var(--bg-surface); + padding: 2px 6px; + border-radius: 3px; + font-family: var(--font-mono); + font-size: 12px; +} + +/* ─── Gap Banner ──────────────────────────────────────── */ +.gap-banner { + background: rgba(251, 191, 36, 0.1); + border-bottom: 1px solid var(--amber); + color: var(--amber); + font-size: 11px; + padding: 6px 12px; + animation: bannerSlide 0.2s ease; +} +@keyframes bannerSlide { + from { transform: translateY(-100%); } + to { transform: translateY(0); } +} + +/* ─── Footer ──────────────────────────────────────────── */ +footer { + height: 32px; + background: var(--bg-header); + border-top: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 12px; + font-size: 11px; + color: var(--text-meta); + flex-shrink: 0; +} +#footer-url { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 60%; +} + +/* ─── Accessibility ───────────────────────────────────── */ +:focus-visible { + outline: 2px solid var(--green); + outline-offset: 1px; +} diff --git a/extension/sidepanel.html b/extension/sidepanel.html new file mode 100644 index 00000000..ca519ea3 --- /dev/null +++ b/extension/sidepanel.html @@ -0,0 +1,62 @@ + + + + + + + + +
+
+ G + gstack +
+
+ + +
+
+ + + + + +
+
+

Waiting for commands...

+

Run a browse command to see activity here.

+
+
+
+ + +
+
+

No refs yet

+

Run snapshot to see element refs.

+
+
+ +
+ + +
+
+

Full session view requires Conductor.

+

Activity tab shows browse commands.

+
+
+ + + + + + + diff --git a/extension/sidepanel.js b/extension/sidepanel.js new file mode 100644 index 00000000..007e22c4 --- /dev/null +++ b/extension/sidepanel.js @@ -0,0 +1,235 @@ +/** + * gstack browse — Side Panel + * + * Connects to browse server SSE stream for live activity. + * Fetches /refs for the Refs tab. + * Cursor-based replay ensures no missed events on reconnect. + */ + +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 pendingEntries = new Map(); // id → entry element (for command_start without command_end) + +// ─── Tab Switching ───────────────────────────────────────────── + +document.querySelectorAll('.tab:not(.disabled)').forEach(tab => { + tab.addEventListener('click', () => { + document.querySelectorAll('.tab').forEach(t => { t.classList.remove('active'); t.setAttribute('aria-selected', 'false'); }); + document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + tab.classList.add('active'); + tab.setAttribute('aria-selected', 'true'); + document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active'); + + if (tab.dataset.tab === 'refs') fetchRefs(); + }); +}); + +// ─── Activity Feed ───────────────────────────────────────────── + +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' }); +} + +function createEntryElement(entry) { + const div = document.createElement('div'); + div.className = `activity-entry ${getEntryClass(entry)}`; + div.setAttribute('role', 'article'); + div.tabIndex = 0; + + const argsText = entry.args ? entry.args.join(' ') : ''; + const statusIcon = entry.status === 'ok' ? '\u2713' : entry.status === 'error' ? '\u2717' : ''; + const statusClass = entry.status === 'ok' ? 'ok' : entry.status === 'error' ? 'err' : ''; + const duration = entry.duration ? `${entry.duration}ms` : ''; + + div.innerHTML = ` +
+ ${formatTime(entry.timestamp)} + ${entry.command || entry.type} +
+ ${argsText ? `
${escapeHtml(argsText)}
` : ''} + ${entry.type === 'command_end' ? ` +
+ ${statusIcon} + ${duration} +
+ ` : ''} + ${entry.result ? ` +
+
${escapeHtml(entry.result)}
+
+ ` : ''} + `; + + // Click to expand/collapse + div.addEventListener('click', () => div.classList.toggle('expanded')); + div.addEventListener('keydown', (e) => { + if (e.key === 'Enter') div.classList.toggle('expanded'); + if (e.key === 'Escape') div.classList.remove('expanded'); + }); + + // Screen reader label + const srLabel = `${entry.command || entry.type} ${argsText} ${statusIcon ? (entry.status === 'ok' ? 'succeeded' : 'failed') : 'in progress'} ${duration ? 'in ' + duration : ''}`; + div.setAttribute('aria-label', srLabel); + + return div; +} + +function addEntry(entry) { + const feed = document.getElementById('activity-feed'); + const empty = document.getElementById('empty-state'); + if (empty) empty.style.display = 'none'; + + // If command_end, update the matching pending entry + if (entry.type === 'command_end') { + // Remove the pending command_start for this command + for (const [id, el] of pendingEntries) { + if (el.querySelector('.entry-command')?.textContent === entry.command) { + el.remove(); + pendingEntries.delete(id); + break; + } + } + } + + const el = createEntryElement(entry); + feed.appendChild(el); + + if (entry.type === 'command_start') { + pendingEntries.set(entry.id, el); + } + + // Auto-scroll + el.scrollIntoView({ behavior: 'smooth', block: 'end' }); + + // Update footer + if (entry.url) document.getElementById('footer-url').textContent = new URL(entry.url).hostname; + const parts = []; + if (entry.tabs) parts.push(`${entry.tabs} tabs`); + if (entry.mode) parts.push(entry.mode); + if (parts.length) document.getElementById('footer-info').textContent = parts.join(' \u00b7 '); + + 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 url = `${serverUrl}/activity/stream?after=${lastId}`; + eventSource = new EventSource(url); + + eventSource.addEventListener('activity', (e) => { + try { + const entry = JSON.parse(e.data); + addEntry(entry); + } 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 (buffer overflow)`; + feed.appendChild(banner); + } catch {} + }); + + eventSource.onerror = () => { + // EventSource auto-reconnects + }; +} + +// ─── Refs Tab ────────────────────────────────────────────────── + +async function fetchRefs() { + if (!serverUrl) return; + try { + const resp = await fetch(`${serverUrl}/refs`, { signal: AbortSignal.timeout(3000) }); + if (!resp.ok) return; + const data = await resp.json(); + + const list = document.getElementById('refs-list'); + const empty = document.getElementById('refs-empty'); + const footer = document.getElementById('refs-footer'); + + if (!data.refs || data.refs.length === 0) { + empty.style.display = ''; + list.innerHTML = ''; + footer.textContent = ''; + return; + } + + empty.style.display = 'none'; + list.innerHTML = data.refs.map(r => ` +
+ ${escapeHtml(r.ref)} + ${escapeHtml(r.role)} + "${escapeHtml(r.name)}" +
+ `).join(''); + footer.textContent = `${data.refs.length} refs \u00b7 ${data.url ? new URL(data.url).hostname : ''}`; + } catch {} +} + +// ─── Server Discovery ────────────────────────────────────────── + +function updateConnection(url) { + serverUrl = url; + if (url) { + document.getElementById('header-dot').className = 'dot connected'; + const port = new URL(url).port; + document.getElementById('header-port').textContent = `:${port}`; + connectSSE(); + } else { + document.getElementById('header-dot').className = 'dot'; + document.getElementById('header-port').textContent = ''; + } +} + +chrome.runtime.sendMessage({ type: 'getServerUrl' }, (resp) => { + if (resp && resp.url) updateConnection(resp.url); +}); + +chrome.runtime.onMessage.addListener((msg) => { + if (msg.type === 'health') { + chrome.runtime.sendMessage({ type: 'getServerUrl' }, (resp) => { + updateConnection(msg.data ? resp?.url : null); + }); + } + if (msg.type === 'refs') { + // Auto-refresh refs tab if visible + if (document.querySelector('.tab[data-tab="refs"].active')) { + fetchRefs(); + } + } +});