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:
Garry Tan
2026-03-21 10:23:47 -07:00
parent f240893ab2
commit 410d0abd9b
14 changed files with 1258 additions and 4 deletions
+43 -4
View File
@@ -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
+108
View File
@@ -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 |
+130
View File
@@ -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);
});
+77
View File
@@ -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;
}
+96
View File
@@ -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

+32
View File
@@ -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"
}
}
+98
View File
@@ -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>
+60
View File
@@ -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}`;
}
});
+317
View File
@@ -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;
}
+62
View File
@@ -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>
+235
View File
@@ -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();
}
}
});