mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-05 05:05:08 +02:00
feat: Chrome extension Side Panel + Conductor API proposal
Chrome extension (Manifest V3, sideload): - Side Panel with live activity feed, @ref overlays, dark terminal aesthetic - Background worker: health polling, SSE relay, ref fetching - Popup: port config, connection status, side panel launcher - Content script: floating ref panel with @ref badges Conductor API proposal (docs/designs/CONDUCTOR_SESSION_API.md): - SSE endpoint for full Claude Code session mirroring in Side Panel - Discovery via HTTP endpoint (not filesystem — extensions can't read files) TODOS.md: add $B watch, multi-agent tabs, cross-platform CDP, Web Store publishing. Mark CDP mode as shipped. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = `<span class="gstack-ref-panel-id">${ref.ref}</span> <span class="gstack-ref-panel-role">${ref.role}</span> <span class="gstack-ref-panel-name">"${ref.name}"</span>`;
|
||||
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();
|
||||
}
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 701 B |
Binary file not shown.
|
After Width: | Height: | Size: 129 B |
Binary file not shown.
|
After Width: | Height: | Size: 267 B |
@@ -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": ["<all_urls>"],
|
||||
"js": ["content.js"],
|
||||
"css": ["content.css"]
|
||||
}],
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
width: 240px;
|
||||
background: #0a0a0a;
|
||||
color: #e0e0e0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
padding: 16px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
input:focus { border-color: #4ade80; }
|
||||
.status {
|
||||
margin: 12px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #555;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot.connected { background: #4ade80; }
|
||||
.dot.error { background: #f87171; }
|
||||
.dot.reconnecting {
|
||||
background: #fbbf24;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
.status-text { color: #888; font-size: 12px; }
|
||||
.status-text.connected { color: #4ade80; }
|
||||
.details { color: #666; font-size: 11px; margin-top: 2px; }
|
||||
button {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
padding: 8px;
|
||||
background: #0a2a14;
|
||||
border: 1px solid #4ade80;
|
||||
border-radius: 4px;
|
||||
color: #4ade80;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
button:hover { background: #0f3a1c; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>gstack</h1>
|
||||
|
||||
<label>Port</label>
|
||||
<input type="text" id="port" placeholder="34567" autocomplete="off">
|
||||
|
||||
<div class="status">
|
||||
<div class="dot" id="dot"></div>
|
||||
<span class="status-text" id="status-text">Disconnected</span>
|
||||
</div>
|
||||
<div class="details" id="details"></div>
|
||||
|
||||
<button id="side-panel-btn">Open Side Panel</button>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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}`;
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="sidepanel.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<span class="monogram">G</span>
|
||||
<span class="title">gstack</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="dot" id="header-dot"></span>
|
||||
<span class="header-port" id="header-port"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Tab Bar -->
|
||||
<nav class="tabs" role="tablist">
|
||||
<button class="tab active" role="tab" aria-selected="true" data-tab="activity">Activity</button>
|
||||
<button class="tab" role="tab" aria-selected="false" data-tab="refs">Refs</button>
|
||||
<button class="tab disabled" role="tab" aria-selected="false" data-tab="session" disabled title="Requires Conductor">Session</button>
|
||||
</nav>
|
||||
|
||||
<!-- Activity Tab -->
|
||||
<main id="tab-activity" class="tab-content active" role="log" aria-live="polite">
|
||||
<div class="empty-state" id="empty-state">
|
||||
<p>Waiting for commands...</p>
|
||||
<p class="muted">Run a browse command to see activity here.</p>
|
||||
</div>
|
||||
<div id="activity-feed"></div>
|
||||
</main>
|
||||
|
||||
<!-- Refs Tab -->
|
||||
<main id="tab-refs" class="tab-content">
|
||||
<div class="empty-state" id="refs-empty">
|
||||
<p>No refs yet</p>
|
||||
<p class="muted">Run <code>snapshot</code> to see element refs.</p>
|
||||
</div>
|
||||
<div id="refs-list"></div>
|
||||
<div class="refs-footer" id="refs-footer"></div>
|
||||
</main>
|
||||
|
||||
<!-- Session Tab -->
|
||||
<main id="tab-session" class="tab-content">
|
||||
<div class="session-placeholder">
|
||||
<p>Full session view requires Conductor.</p>
|
||||
<p class="muted">Activity tab shows browse commands.</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer>
|
||||
<span id="footer-url"></span>
|
||||
<span id="footer-info"></span>
|
||||
</footer>
|
||||
|
||||
<script src="sidepanel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 = `
|
||||
<div class="entry-header">
|
||||
<span class="entry-time">${formatTime(entry.timestamp)}</span>
|
||||
<span class="entry-command">${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>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
// 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 => `
|
||||
<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 \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();
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user