mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-01 15:51:41 +02:00
19770ea8b4
* add withCdpSession + getOrCreateCdpSession helpers
Two CDP-session lifecycle helpers in cdp-bridge.ts:
- withCdpSession(page, fn): ephemeral session with try/finally detach.
For one-shot CDP work (archive snapshots, $B memory, single
Page.captureScreenshot) where the caller doesn't need session reuse.
- getOrCreateCdpSession(page, cache): cached long-lived session that
registers a page.once('close') hook to BOTH delete the cache entry
AND call session.detach(). Pre-helper code only deleted the cache
entry, leaving the Chromium-side CDP target attached until the
underlying transport dropped.
Pure addition. Existing callers untouched in this commit; they migrate
in the next commit alongside the static-grep test that pins the
invariant.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* migrate 3 CDP-session sites to lifecycle helpers
Fixes the CDP-target leak class identified by /codex outside-voice on
the eng review (D11 EXPAND_SCOPE). All three sites called
`page.context().newCDPSession(page)` directly and either forgot the
detach entirely (cdp-bridge cache cleanup), only detached on the
success path (write-commands archive), or detached on framenavigated
but not page-close (cdp-inspector).
- cdp-bridge.ts: `getCdpSession` now delegates to
`getOrCreateCdpSession`, which registers a `page.once('close')` hook
that BOTH removes the cache entry AND calls `session.detach()`.
- cdp-inspector.ts: same migration for the inspector's session pool.
Keeps the existing framenavigated detach (more granular than close
for DOM/CSS state invalidation) plus an inspector-layer close hook
for the initializedPages WeakSet.
- write-commands.ts archive: wraps Page.captureSnapshot in
withCdpSession so the detach runs in `finally`, including the path
where captureSnapshot throws.
The static-grep tripwire (next commit) pins the invariant so future
direct calls to newCDPSession fail CI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* add CDP-session cleanup tripwire + helper unit tests
browse/test/cdp-session-cleanup.test.ts pins the invariant that no
source file outside cdp-bridge.ts may call newCDPSession() directly.
If a future refactor reintroduces the direct call, CI fails with a
file:line list and a pointer to the right helper to use instead
(withCdpSession for one-shot, getOrCreateCdpSession for cached).
Also covers the helpers themselves with fake-Page unit tests:
- withCdpSession detaches on success
- withCdpSession detaches on throw (the actual leak fix)
- withCdpSession swallows detach errors so they don't mask fn errors
- getOrCreateCdpSession caches the session across calls
- close hook detaches AND clears the cache
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* extract createSseEndpoint helper with cleanup contract
browse/src/sse-helpers.ts owns the SSE cleanup invariant:
cleanup runs on abort, enqueue failure, AND heartbeat failure,
exactly once, regardless of which edge fires first.
Pre-helper, /activity/stream and /inspector/events ran cleanup only on
the req.signal.abort edge. If the underlying TCP died without firing
abort (Chromium MV3 service-worker suspend, intermediate proxy
half-close), the subscriber closure stayed in the Set capturing the
ReadableStreamDefaultController plus any payloads queued behind it. Over
a multi-day sidebar session this compounded into multi-MB of retained
controllers per dead connection.
Caller surface: initialReplay (optional, for gap replay or state
snapshots), subscribe (live-event source), liveEventName (SSE event
name for live wrap), heartbeatMs. send() helper handles JSON encoding
with sanitizeReplacer + lone-surrogate stripping.
Unit tests pin all three cleanup edges + idempotency + replay ordering
+ surrogate sanitization. Endpoint refactors land in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* route /activity/stream + /inspector/events through createSseEndpoint
Both endpoints collapse from ~45 lines of in-line ReadableStream wiring
to ~8 lines of helper config. Behavior preserved bit-for-bit by the
new sse-helpers tests:
- initial replay (activity gap + history, inspector state snapshot)
- live event subscription
- 15s heartbeat
- SSE framing
- sanitizeReplacer applied to every JSON.stringify
The leak fix is the cleanup contract: pre-refactor, both endpoints ran
cleanup only on req.signal.abort. If TCP died without firing abort
(Chromium MV3 SW suspend, intermediate proxy half-close), the
subscriber closure stayed in the Set forever capturing the
ReadableStreamDefaultController + queued payloads. Post-refactor, an
enqueue-failure or heartbeat-failure on a dead consumer triggers the
same idempotent cleanup as abort would.
Net: -83 / +15 in server.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* cap inspector modificationHistory at 200 entries
Pre-cap, modificationHistory was an unbounded module-scoped array that
grew for every CSS edit through $B css across the entire session.
Small per-entry footprint but no upper bound, the kind of slow leak
that compounds over multi-day inspector use.
Cap is 200, oldest evicted on push past the cap. modHistoryTotalPushed
stays monotonic across the session so undoModification can tell the
user when their target index has been evicted, instead of just the
opaque pre-cap "No modification at index 500" with no context.
__testInternals export lets the cap + eviction error be unit-tested
without spinning up a CDP-driven Page. Production code must continue
to go through modifyStyle / undoModification / resetModifications.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* add BrowserManager.getMemorySnapshot() + shared types
Diagnostic foundation for $B memory and the /memory endpoint that land
in the next two commits. Collects:
- Bun process memory via process.memoryUsage (cross-platform, accurate).
- Per-tab JS heap via CDP Performance.getMetrics, lazy per tracked page,
swallows target-died errors so a dying tab doesn't poison the
snapshot for the rest.
- Chromium process tree via SystemInfo.getProcessInfo (PID + type +
CPU time). RSS is NOT exposed via CDP — the eng review (D2 USE_CDP)
picked CDP over shelling to `ps`, so notes[] tells the caller why
the RSS column is absent and points at the follow-up TODO.
cdp-inspector exports getModificationHistoryStats so the snapshot can
surface buffer occupancy + cap + evicted count without reaching into
module-private state.
memory-snapshot.ts holds the shared types so server.ts and read-commands
can import without circular dep on browser-manager.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* add \$B memory command
Registers 'memory' in META_COMMANDS, wires the meta-command dispatch
to a lazy-imported handler in memory-command.ts. Lazy because the
import graph (cdp-bridge + memory-snapshot + buffer accessors) isn't
useful to projects that never run the diagnostic.
The handler assembles MemoryStructureStats from the modules that own
each buffer (cdp-inspector mod history stats, activity subscriber
count, console/network/dialog buffer lengths, captureBuffer bytes,
inspectorSubscriber count via a new server.ts export) and calls
BrowserManager.getMemorySnapshot. Output is text by default, JSON with
--json so the sidebar footer and test harness can consume it
programmatically. buildMemorySnapshotJson is the entry the /memory
endpoint will call in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* add /memory endpoint (SSE-session-cookie gated)
GET /memory returns the BrowserManager memory snapshot as JSON. Auth
matches /activity/stream and /inspector/events: Bearer header OR
view-only SSE-session cookie (the extension fetches the cookie once
via POST /sse-session, then polls /memory with withCredentials: true).
Deliberately NOT extending /health for the sidebar footer poll —
TODOS.md "Audit /health token distribution" records that /health
already surfaces AUTH_TOKEN to any localhost caller in headed mode. A
separate endpoint with the standard SSE auth keeps the future /health
fix from cascading into the sidebar.
sanitizeReplacer is applied at egress because tab.url and tab.title
come from page content — lone-surrogate bytes from broken emoji could
otherwise reach the sidebar and (when forwarded to Claude API) trigger
HTTP 400.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* add sidebar footer RSS readout (polls /memory every 30s)
Footer now shows "<bun-rss> · <tab-count>" sourced from the /memory
endpoint, polled every 30s. Color thresholds: orange warn at 2 GB Bun
RSS or 50 tabs; red bad at 8 GB or 200 tabs (matches the tab-guardrail
threshold landing in a later commit). The footer gives the user an
early signal that the cliff is forming, instead of only learning when
the OS OOM-kills the process.
Backoff per Codex's flag: if a poll takes > 2s response time the
sidebar drops to a 5-minute cadence until the next successful fast
poll. The diagnostic shouldn't add load to a browser that's already
unhealthy.
Start/stop is wired to the existing setServerInfo() hook so the timer
only runs while the sidebar is connected to a server.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* stop materializing response bodies in requestfinished listener
The Bun-side accelerant on the gbrowser-OOM investigation. Pre-fix,
the per-page requestfinished listener called \`await res.body()\` just
to read .length — Playwright fetches the bytes from Chromium across
CDP into a Bun Buffer, only for the listener to discard the buffer
after a single length read. On a long-lived headed browser with
media-heavy pages this is multi-GB/hour of Buffer allocation churn.
Bun GCs it, but the cross-process CDP traffic + transient allocation
pressure feeds the OOM trajectory.
The fix: req.sizes() pulls from the Network.loadingFinished event
Chromium already emits. No body materialization. Accurate for chunked
transfer, gzip-compressed responses, and streaming media — the cases
where a naive Content-Length header read (the original review's
proposal) would have missed the size entirely (Codex flag on the eng
review, D10 USE_CDP_EVENT_BATCHED).
The D10 stretch goal — replacing N per-page listeners with a single
context-level CDP listener via Target.setAutoAttach — is deferred and
tracked in TODOS. The listener architecture change is significantly
more plumbing than the leak fix and not on the critical path for
stopping the body materialization.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* tab guardrail (50/200 thresholds) + sidebar action toast
Server side (browser-manager.ts):
Idempotent threshold tracker fires an activity entry exactly once at
each upward crossing of 50 (soft warn) and 200 (hard warn). Re-arms
when the count drops below. Activity-feed surface gives the
audit-trail invariant even with the sidebar closed; the toast UX
lives in the sidebar.
Sidebar side (extension/sidepanel.{html,css,js}):
Every /memory poll evaluates two trigger conditions:
- Any single tab > 4 GB JS heap (catches the WebGL/video runaway
case Codex flagged on the eng review).
- Tab count >= 200.
Toast shows top 5 tabs ranked by max(jsHeap, nodes*1KB + listeners*200)
so a WebGL-heavy tab with small JS heap still surfaces. Default-selected
checkboxes + "Close selected" run \`\$B closetab <id>\` through the
existing /command path — no chrome.tabs.remove bridge needed. "Snooze"
bumps tabsAbove/heapAbove thresholds in chrome.storage.session so the
toast stays hidden until the user accumulates more tabs OR one tab
grows another 2 GB.
Tests: browse/test/tab-guardrail.test.ts pins the server-side
fires-once + re-arms invariants without spinning up Chromium.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* add memory-leak reproducer (gate tier)
browse/test/memory-leak-reproducer.test.ts pins the invariant from
the D10 fix: wirePageEvents.requestfinished must call req.sizes() but
must NEVER call res.body(). Fakes a page emitting a burst of 200
requestfinished events, each with a notional 1 MB response — pre-fix
this would allocate 200 MB of Buffer per burst, post-fix not one byte
of body content is materialized.
The test also asserts networkBuffer entries are still populated with
the right size, so size reporting in the network panel doesn't
regress.
A real-Chromium peak-RSS reproducer (periodic tier) is deferred —
see TODOS "Reproducer with WebGL / video / MSE buffer pressure". This
gate-tier test is sufficient to catch the leak class being
reintroduced by any future refactor of the requestfinished listener.
Wall clock: ~400ms.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* TODOS: 4 follow-ups from gbrowser-OOM PR
Captures the items deliberately deferred from the v1.49 leak-fix PR
so the deferrals don't fall off the radar:
- P2: MV3 extension service-worker memory profile (Codex finding #4)
- P2: Native + GPU memory breakdown in \$B memory (Codex finding #5)
- P3: Single-context CDP listener for Network.loadingFinished (D10
stretch goal)
- P3: Real-Chromium peak-RSS reproducer for periodic tier (Codex
finding on transient amplification + ANGLE_B_NUMBERS CHANGELOG
framing dependency)
Each entry follows the standard TODOS.md format: What / Why / Pros /
Cons / Context / Priority / Effort.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* regen SKILL.md after adding \$B memory command
The C8 commit added 'memory' to META_COMMANDS + COMMAND_DESCRIPTIONS
but didn't regenerate the SKILL.md files. The category was 'Diagnostics'
which isn't in scripts/resolvers/browse.ts:categoryOrder; switched to
'Server' (matches the existing 'status' / 'restart' / 'handoff'
pattern) so the table renders under the existing ### Server section.
Test fix: gen-skill-docs.test.ts asserts every command appears in the
generated SKILL.md and gstack/llms.txt; without this regen the test
fails with "Expected to contain: 'memory'".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* add coverage for \$B memory diagnostic surface
17 tests across the formatter + byte renderer + JSON entry point:
- formatBytes() 4-tier (bytes, KB, MB, GB) + 160 GB sanity case
(the friend's OOM number from the original screenshot, so the
renderer doesn't blow up at real leak scale)
- handleMemoryCommand --json mode parseable shape
- handleMemoryCommand text mode: Bun server line, no-tabs branch,
top-10 sort with "...and N more" tail, Chromium process grouping
by type, "unavailable" line when processes is null, modification-
history evicted-count format, notes section rendering, long-URL
ellipsis truncation
- buildMemorySnapshotJson returns shape matching the type
The formatSnapshotText renderer is private to memory-command.ts;
tests exercise it through handleMemoryCommand's text-mode return
path. The eviction-count format is pinned via a parallel format
contract assertion since the renderer reads live module state.
Coverage gate: brings the diagnostic surface from 0% to ~80%.
Extension UI (sidepanel.js footer + toast) remains uncovered —
adding tests there would require extracting fmtBytesShort and
tabRamScore from sidepanel.js into a testable TS module, which is
deferred to a follow-up to keep this PR scoped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v1.51.0.0)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: update project documentation for v1.51.0.0
Add $B memory command to BROWSER.md server lifecycle table. Document the
new createSseEndpoint helper + CDP session lifecycle helpers (withCdpSession,
getOrCreateCdpSession) in CLAUDE.md alongside the existing server hardening
notes, with the static-grep tripwire callout so future contributors route
through the helpers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(test): pin SSE sanitizer wiring to the v1.51 createSseEndpoint helper
The two `wiring invariants` tests grepped server.ts for
`JSON.stringify(entry, sanitizeReplacer)` and
`JSON.stringify(event, sanitizeReplacer)` — patterns that lived inline
in /activity/stream and /inspector/events before the v1.51 refactor
moved both endpoints behind createSseEndpoint. Sanitization still
happens (the helper applies it inside its send() and live-event
callback), but the static-grep was pinned to the old wiring and started
failing on Windows free-tests after the refactor landed.
Updated to check the new contract:
- /activity/stream + /inspector/events route through createSseEndpoint
(regex match of the route handler block ending in the helper call).
- sse-helpers.ts contains JSON.stringify + sanitizeReplacer + imports
stripLoneSurrogates from ./sanitize (catches drift to a private copy).
- server.ts retains its own sanitizeReplacer for non-SSE egress paths
(handleCommandInternal); the two replacers coexist by design.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1416 lines
54 KiB
JavaScript
1416 lines
54 KiB
JavaScript
/**
|
||
* gstack browse — Side Panel
|
||
*
|
||
* Terminal pane (default): live claude PTY via xterm.js, driven by
|
||
* sidepanel-terminal.js. The chat queue + sidebar-agent.ts were ripped
|
||
* in favor of the interactive REPL — no more one-shot claude -p.
|
||
*
|
||
* Debug tabs (behind the `debug` toggle): activity feed (SSE) + refs +
|
||
* inspector. Quick-actions toolbar (Cleanup / Screenshot / Cookies)
|
||
* lives at the top of the Terminal pane.
|
||
*/
|
||
|
||
const NAV_COMMANDS = new Set(['goto', 'back', 'forward', 'reload']);
|
||
const INTERACTION_COMMANDS = new Set(['click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait', 'upload']);
|
||
const OBSERVE_COMMANDS = new Set(['snapshot', 'screenshot', 'diff', 'console', 'network', 'text', 'html', 'links', 'forms', 'accessibility', 'cookies', 'storage', 'perf']);
|
||
|
||
let lastId = 0;
|
||
let eventSource = null;
|
||
let serverUrl = null;
|
||
let serverToken = null;
|
||
let connState = 'disconnected'; // disconnected | connected | reconnecting | dead
|
||
let reconnectAttempts = 0;
|
||
let reconnectTimer = null;
|
||
const MAX_RECONNECT_ATTEMPTS = 30; // 30 * 2s = 60s before showing "dead"
|
||
|
||
// Auth headers for sidebar endpoints
|
||
function authHeaders() {
|
||
const h = { 'Content-Type': 'application/json' };
|
||
if (serverToken) h['Authorization'] = `Bearer ${serverToken}`;
|
||
return h;
|
||
}
|
||
|
||
// ─── Connection State Machine ─────────────────────────────────────
|
||
|
||
function setConnState(state) {
|
||
const prev = connState;
|
||
connState = state;
|
||
const banner = document.getElementById('conn-banner');
|
||
const bannerText = document.getElementById('conn-banner-text');
|
||
const bannerActions = document.getElementById('conn-banner-actions');
|
||
|
||
if (state === 'connected') {
|
||
if (prev === 'reconnecting' || prev === 'dead') {
|
||
// Show "reconnected" toast that fades
|
||
banner.style.display = '';
|
||
banner.className = 'conn-banner reconnected';
|
||
bannerText.textContent = 'Reconnected';
|
||
bannerActions.style.display = 'none';
|
||
setTimeout(() => { banner.style.display = 'none'; }, 5000);
|
||
} else {
|
||
banner.style.display = 'none';
|
||
}
|
||
reconnectAttempts = 0;
|
||
if (reconnectTimer) { clearInterval(reconnectTimer); reconnectTimer = null; }
|
||
} else if (state === 'reconnecting') {
|
||
banner.style.display = '';
|
||
banner.className = 'conn-banner reconnecting';
|
||
bannerText.textContent = `Reconnecting... (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`;
|
||
bannerActions.style.display = 'none';
|
||
} else if (state === 'dead') {
|
||
banner.style.display = '';
|
||
banner.className = 'conn-banner dead';
|
||
bannerText.textContent = 'Server offline';
|
||
bannerActions.style.display = '';
|
||
if (reconnectTimer) { clearInterval(reconnectTimer); reconnectTimer = null; }
|
||
} else {
|
||
banner.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function startReconnect() {
|
||
if (reconnectTimer) return;
|
||
setConnState('reconnecting');
|
||
reconnectTimer = setInterval(() => {
|
||
reconnectAttempts++;
|
||
if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
|
||
setConnState('dead');
|
||
return;
|
||
}
|
||
setConnState('reconnecting');
|
||
tryConnect();
|
||
}, 2000);
|
||
}
|
||
|
||
|
||
// ─── Chat path ripped ────────────────────────────────────────────
|
||
// Chat queue + sendMessage + pollChat + switchChatTab + browser-tabs
|
||
// strip + security banner all lived here. Replaced by the interactive
|
||
// claude PTY in sidepanel-terminal.js (and terminal-agent.ts on the
|
||
// server side).
|
||
|
||
// ─── Reload Sidebar ─────────────────────────────────────────────
|
||
document.getElementById('reload-sidebar').addEventListener('click', () => {
|
||
location.reload();
|
||
});
|
||
|
||
// ─── Copy Cookies ───────────────────────────────────────────────
|
||
document.getElementById('chat-cookies-btn').addEventListener('click', async () => {
|
||
if (!serverUrl) return;
|
||
// Navigate the browser to the cookie picker page hosted by the browse server
|
||
try {
|
||
await fetch(`${serverUrl}/command`, {
|
||
method: 'POST',
|
||
headers: authHeaders(),
|
||
body: JSON.stringify({ command: 'goto', args: [`${serverUrl}/cookie-picker`] }),
|
||
});
|
||
} catch (err) {
|
||
console.error('[gstack sidebar] Failed to open cookie picker:', err.message);
|
||
}
|
||
});
|
||
|
||
// ─── Debug Tabs ─────────────────────────────────────────────────
|
||
|
||
const debugToggle = document.getElementById('debug-toggle');
|
||
const debugTabs = document.getElementById('debug-tabs');
|
||
const closeDebug = document.getElementById('close-debug');
|
||
let debugOpen = false;
|
||
|
||
// The Terminal pane is the only primary surface; Activity / Refs / Inspector
|
||
// are debug overlays behind the `debug` toggle. Closing debug returns to
|
||
// the Terminal pane, which is always present.
|
||
const PRIMARY_PANE_ID = 'tab-terminal';
|
||
|
||
function showPrimaryPane() {
|
||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||
document.getElementById(PRIMARY_PANE_ID).classList.add('active');
|
||
document.querySelectorAll('.debug-tabs .tab').forEach(t => t.classList.remove('active'));
|
||
}
|
||
|
||
debugToggle.addEventListener('click', () => {
|
||
debugOpen = !debugOpen;
|
||
debugToggle.classList.toggle('active', debugOpen);
|
||
debugTabs.style.display = debugOpen ? 'flex' : 'none';
|
||
if (!debugOpen) showPrimaryPane();
|
||
});
|
||
|
||
closeDebug.addEventListener('click', () => {
|
||
debugOpen = false;
|
||
debugToggle.classList.remove('active');
|
||
debugTabs.style.display = 'none';
|
||
showPrimaryPane();
|
||
});
|
||
|
||
document.querySelectorAll('.debug-tabs .tab:not(.close-debug)').forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
document.querySelectorAll('.debug-tabs .tab').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||
tab.classList.add('active');
|
||
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active');
|
||
|
||
if (tab.dataset.tab === 'refs') fetchRefs();
|
||
});
|
||
});
|
||
|
||
// ─── Activity Feed ──────────────────────────────────────────────
|
||
|
||
function getEntryClass(entry) {
|
||
if (entry.status === 'error') return 'error';
|
||
if (entry.type === 'command_start') return 'pending';
|
||
const cmd = entry.command || '';
|
||
if (NAV_COMMANDS.has(cmd)) return 'nav';
|
||
if (INTERACTION_COMMANDS.has(cmd)) return 'interaction';
|
||
if (OBSERVE_COMMANDS.has(cmd)) return 'observe';
|
||
return '';
|
||
}
|
||
|
||
function formatTime(ts) {
|
||
const d = new Date(ts);
|
||
return d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||
}
|
||
|
||
let pendingEntries = new Map();
|
||
|
||
function createEntryElement(entry) {
|
||
const div = document.createElement('div');
|
||
div.className = `activity-entry ${getEntryClass(entry)}`;
|
||
div.setAttribute('role', 'article');
|
||
div.tabIndex = 0;
|
||
|
||
const argsText = entry.args ? entry.args.join(' ') : '';
|
||
const statusIcon = entry.status === 'ok' ? '\u2713' : entry.status === 'error' ? '\u2717' : '';
|
||
const statusClass = entry.status === 'ok' ? 'ok' : entry.status === 'error' ? 'err' : '';
|
||
const duration = entry.duration ? `${entry.duration}ms` : '';
|
||
|
||
div.innerHTML = `
|
||
<div class="entry-header">
|
||
<span class="entry-time">${formatTime(entry.timestamp)}</span>
|
||
<span class="entry-command">${escapeHtml(entry.command || entry.type)}</span>
|
||
</div>
|
||
${argsText ? `<div class="entry-args">${escapeHtml(argsText)}</div>` : ''}
|
||
${entry.type === 'command_end' ? `
|
||
<div class="entry-status">
|
||
<span class="${statusClass}">${statusIcon}</span>
|
||
<span class="duration">${duration}</span>
|
||
</div>
|
||
` : ''}
|
||
${entry.result ? `
|
||
<div class="entry-detail">
|
||
<div class="entry-result">${escapeHtml(entry.result)}</div>
|
||
</div>
|
||
` : ''}
|
||
`;
|
||
|
||
div.addEventListener('click', () => div.classList.toggle('expanded'));
|
||
return div;
|
||
}
|
||
|
||
function addEntry(entry) {
|
||
const feed = document.getElementById('activity-feed');
|
||
const empty = document.getElementById('empty-state');
|
||
if (empty) empty.style.display = 'none';
|
||
|
||
if (entry.type === 'command_end') {
|
||
for (const [id, el] of pendingEntries) {
|
||
if (el.querySelector('.entry-command')?.textContent === entry.command) {
|
||
el.remove();
|
||
pendingEntries.delete(id);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
const el = createEntryElement(entry);
|
||
feed.appendChild(el);
|
||
if (entry.type === 'command_start') pendingEntries.set(entry.id, el);
|
||
el.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||
|
||
if (entry.url) document.getElementById('footer-url')?.textContent && (document.getElementById('footer-url').textContent = new URL(entry.url).hostname);
|
||
lastId = Math.max(lastId, entry.id);
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
const div = document.createElement('div');
|
||
div.textContent = str;
|
||
// DOM text-node serialization escapes &, <, > but NOT " or '. Call sites
|
||
// that interpolate escapeHtml output inside an attribute value (title="...",
|
||
// data-x="...") need those escaped too or an attacker-controlled value can
|
||
// break out of the attribute. Add both manually.
|
||
return div.innerHTML
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
// ─── SSE Connection ─────────────────────────────────────────────
|
||
|
||
// Fetch a view-only SSE session cookie before opening EventSource.
|
||
// EventSource can't send Authorization headers, and putting the root
|
||
// token in the URL (the old ?token= path) leaks it to logs, referer
|
||
// headers, and browser history. POST /sse-session issues an HttpOnly
|
||
// SameSite=Strict cookie scoped to SSE reads only; withCredentials:true
|
||
// on EventSource makes the browser send it back.
|
||
async function ensureSseSessionCookie() {
|
||
if (!serverUrl || !serverToken) return false;
|
||
try {
|
||
const resp = await fetch(`${serverUrl}/sse-session`, {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: { 'Authorization': `Bearer ${serverToken}` },
|
||
});
|
||
return resp.ok;
|
||
} catch (err) {
|
||
console.warn('[gstack sidebar] Failed to mint SSE session cookie:', err && err.message);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function connectSSE() {
|
||
if (!serverUrl) return;
|
||
if (eventSource) { eventSource.close(); eventSource = null; }
|
||
|
||
await ensureSseSessionCookie();
|
||
const url = `${serverUrl}/activity/stream?after=${lastId}`;
|
||
eventSource = new EventSource(url, { withCredentials: true });
|
||
|
||
eventSource.addEventListener('activity', (e) => {
|
||
try { addEntry(JSON.parse(e.data)); } catch (err) {
|
||
console.error('[gstack sidebar] Failed to parse activity event:', err.message);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener('gap', (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
const feed = document.getElementById('activity-feed');
|
||
const banner = document.createElement('div');
|
||
banner.className = 'gap-banner';
|
||
banner.textContent = `Missed ${data.availableFrom - data.gapFrom} events`;
|
||
feed.appendChild(banner);
|
||
} catch (err) {
|
||
console.error('[gstack sidebar] Failed to parse gap event:', err.message);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ─── Memory Footer Readout ──────────────────────────────────────
|
||
//
|
||
// Polls /memory every 30s and renders "RSS: 1.4 GB · 12 tabs" in the
|
||
// footer. Backs off to 5min if a poll takes > 2s (Codex flag — diagnostic
|
||
// shouldn't add load when the browser is already unhealthy). Uses Bearer
|
||
// auth like /refs above; /memory is a plain GET so EventSource semantics
|
||
// don't apply.
|
||
|
||
const MEM_POLL_FAST_MS = 30_000;
|
||
const MEM_POLL_SLOW_MS = 5 * 60_000;
|
||
const MEM_POLL_TIMEOUT_MS = 8_000;
|
||
const MEM_POLL_SLOW_THRESHOLD_MS = 2_000;
|
||
let memPollTimer = null;
|
||
let memPollMode = 'fast'; // 'fast' | 'slow'
|
||
|
||
function fmtBytesShort(n) {
|
||
if (typeof n !== 'number' || isNaN(n)) return '?';
|
||
if (n < 1024) return n + ' B';
|
||
if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
|
||
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(0) + ' MB';
|
||
return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB';
|
||
}
|
||
|
||
function renderMemFooter(snapshot) {
|
||
const el = document.getElementById('footer-mem');
|
||
if (!el) return;
|
||
const bunRss = snapshot?.bunServer?.rss ?? 0;
|
||
const tabCount = Array.isArray(snapshot?.tabs) ? snapshot.tabs.length : 0;
|
||
el.textContent = `${fmtBytesShort(bunRss)} · ${tabCount} tabs`;
|
||
// Color thresholds: ~2 GB Bun RSS or 50 tabs is "watch this"; ~8 GB or
|
||
// 200 tabs is "this is the cliff" (matches the 200-tab guardrail).
|
||
el.classList.remove('warn', 'bad');
|
||
if (bunRss > 8 * 1024 * 1024 * 1024 || tabCount > 200) el.classList.add('bad');
|
||
else if (bunRss > 2 * 1024 * 1024 * 1024 || tabCount > 50) el.classList.add('warn');
|
||
}
|
||
|
||
async function pollMemoryOnce() {
|
||
if (!serverUrl || !serverToken) return { ok: false, slow: false };
|
||
const start = Date.now();
|
||
try {
|
||
const resp = await fetch(`${serverUrl}/memory`, {
|
||
headers: { 'Authorization': `Bearer ${serverToken}` },
|
||
signal: AbortSignal.timeout(MEM_POLL_TIMEOUT_MS),
|
||
credentials: 'include',
|
||
});
|
||
const elapsed = Date.now() - start;
|
||
if (!resp.ok) return { ok: false, slow: elapsed > MEM_POLL_SLOW_THRESHOLD_MS };
|
||
const snapshot = await resp.json();
|
||
renderMemFooter(snapshot);
|
||
// Evaluate guardrail triggers (single-heavy-tab OR tab-count crossing 200).
|
||
// Toast is hidden when no trigger fires; snooze state suppresses re-fire.
|
||
try { evaluateMemToast(snapshot); } catch (err) {
|
||
console.debug('[gstack sidebar] mem-toast evaluation failed:', err && err.message);
|
||
}
|
||
return { ok: true, slow: elapsed > MEM_POLL_SLOW_THRESHOLD_MS };
|
||
} catch (err) {
|
||
const elapsed = Date.now() - start;
|
||
// Don't log every poll failure — common during browser restarts / restoring
|
||
// sessions. Only log on the slow path so the user sees something in the
|
||
// console if the diagnostic itself is misbehaving.
|
||
if (elapsed > MEM_POLL_SLOW_THRESHOLD_MS) {
|
||
console.debug('[gstack sidebar] /memory poll slow/failed:', elapsed, 'ms', err && err.message);
|
||
}
|
||
return { ok: false, slow: elapsed > MEM_POLL_SLOW_THRESHOLD_MS };
|
||
}
|
||
}
|
||
|
||
function scheduleNextMemPoll(delayMs) {
|
||
if (memPollTimer) clearTimeout(memPollTimer);
|
||
memPollTimer = setTimeout(async () => {
|
||
const { ok, slow } = await pollMemoryOnce();
|
||
if (!ok || slow) {
|
||
memPollMode = 'slow';
|
||
scheduleNextMemPoll(MEM_POLL_SLOW_MS);
|
||
} else {
|
||
// Successful + fast → back to fast cadence.
|
||
if (memPollMode === 'slow') memPollMode = 'fast';
|
||
scheduleNextMemPoll(MEM_POLL_FAST_MS);
|
||
}
|
||
}, delayMs);
|
||
}
|
||
|
||
function startMemPolling() {
|
||
if (memPollTimer) return; // already running
|
||
// Kick off an immediate poll so the footer populates within ~1s of sidebar
|
||
// open, instead of waiting 30s for the first cycle.
|
||
scheduleNextMemPoll(500);
|
||
}
|
||
|
||
function stopMemPolling() {
|
||
if (memPollTimer) {
|
||
clearTimeout(memPollTimer);
|
||
memPollTimer = null;
|
||
}
|
||
}
|
||
|
||
// ─── Tab guardrail toast (D5 + Codex single-tab flag) ───────
|
||
//
|
||
// Each /memory poll evaluates two trigger conditions:
|
||
// 1. Tab count crossed 200 — show "top 5 tabs by max(jsHeap, ...)" with
|
||
// Close-selected + Snooze.
|
||
// 2. Any single tab over 4 GB JS heap — show one-tab toast (catches the
|
||
// Codex case where a runaway WebGL/video page balloons one tab).
|
||
// Snooze persists in chrome.storage.session: next warn fires at tabCount +
|
||
// snoozeBumpTabs OR when a single tab crosses (snoozedJsHeapBytes + 1).
|
||
//
|
||
// "Close selected" runs $B closetab <id> via the existing /command path —
|
||
// no chrome.tabs.remove bridge needed.
|
||
|
||
const HEAVY_TAB_HEAP_BYTES = 4 * 1024 * 1024 * 1024; // 4 GB per Codex flag
|
||
const TOAST_SNOOZE_TAB_BUMP = 50; // re-warn at 200+50
|
||
const TOAST_SNOOZE_HEAP_BUMP = 2 * 1024 * 1024 * 1024;
|
||
|
||
const memToastSnooze = {
|
||
tabsAbove: 0, // suppress the count-toast until tabs strictly exceeds this
|
||
heapAbove: 0, // suppress the single-tab toast until heap strictly exceeds this
|
||
};
|
||
|
||
async function loadSnoozeState() {
|
||
if (!chrome?.storage?.session) return;
|
||
try {
|
||
const stored = await chrome.storage.session.get(['memToastSnooze']);
|
||
if (stored?.memToastSnooze) {
|
||
memToastSnooze.tabsAbove = stored.memToastSnooze.tabsAbove | 0;
|
||
memToastSnooze.heapAbove = stored.memToastSnooze.heapAbove | 0;
|
||
}
|
||
} catch (err) {
|
||
console.debug('[gstack sidebar] mem-toast snooze load failed:', err && err.message);
|
||
}
|
||
}
|
||
|
||
async function saveSnoozeState() {
|
||
if (!chrome?.storage?.session) return;
|
||
try {
|
||
await chrome.storage.session.set({ memToastSnooze: { ...memToastSnooze } });
|
||
} catch (err) {
|
||
console.debug('[gstack sidebar] mem-toast snooze save failed:', err && err.message);
|
||
}
|
||
}
|
||
|
||
function dismissMemToast() {
|
||
const toast = document.getElementById('mem-toast');
|
||
if (toast) toast.style.display = 'none';
|
||
}
|
||
|
||
/**
|
||
* Sort key for "RAM-heavy" tabs. JS heap × 4 is a rough proxy for total
|
||
* tab footprint (renderers tend to spend ~4× their JS heap on native +
|
||
* Skia + cache); when a tab is heavy via WebGL/video the JS heap is
|
||
* small but listeners/nodes spike. Take the max.
|
||
*/
|
||
function tabRamScore(tab) {
|
||
const heap = tab?.jsHeapUsed || 0;
|
||
const nodes = tab?.nodes || 0;
|
||
const listeners = tab?.listeners || 0;
|
||
// ~1 KB per DOM node + ~200 bytes per listener as a back-of-envelope
|
||
// native-memory estimate. Keeps the sort meaningful when JS heap is small.
|
||
const nativeEstimate = nodes * 1024 + listeners * 200;
|
||
return Math.max(heap, nativeEstimate);
|
||
}
|
||
|
||
function showMemToast(title, body, tabsForClose) {
|
||
const toast = document.getElementById('mem-toast');
|
||
const titleEl = document.getElementById('mem-toast-title');
|
||
const bodyEl = document.getElementById('mem-toast-body');
|
||
const closeBtn = document.getElementById('mem-toast-close-selected');
|
||
if (!toast || !titleEl || !bodyEl || !closeBtn) return;
|
||
|
||
titleEl.textContent = title;
|
||
bodyEl.innerHTML = '';
|
||
|
||
for (const t of tabsForClose) {
|
||
const row = document.createElement('div');
|
||
row.className = 'mem-toast-row';
|
||
const cb = document.createElement('input');
|
||
cb.type = 'checkbox';
|
||
cb.id = `mem-toast-tab-${t.id}`;
|
||
cb.value = String(t.id);
|
||
cb.checked = true; // default-selected so a fast user just hits Close
|
||
const label = document.createElement('label');
|
||
label.htmlFor = cb.id;
|
||
const urlShort = (t.url || '').length > 50 ? t.url.slice(0, 47) + '...' : (t.url || '(no url)');
|
||
label.textContent = `tab #${t.id} — ${urlShort}`;
|
||
const size = document.createElement('span');
|
||
size.className = 'mem-toast-size';
|
||
size.textContent = fmtBytesShort(tabRamScore(t));
|
||
row.appendChild(cb);
|
||
row.appendChild(label);
|
||
row.appendChild(size);
|
||
bodyEl.appendChild(row);
|
||
}
|
||
|
||
toast.style.display = '';
|
||
|
||
closeBtn.onclick = async () => {
|
||
const ids = tabsForClose
|
||
.filter((t) => document.getElementById(`mem-toast-tab-${t.id}`)?.checked)
|
||
.map((t) => t.id);
|
||
dismissMemToast();
|
||
for (const id of ids) {
|
||
try {
|
||
await fetch(`${serverUrl}/command`, {
|
||
method: 'POST',
|
||
headers: authHeaders(),
|
||
body: JSON.stringify({ command: 'closetab', args: [String(id)] }),
|
||
});
|
||
} catch (err) {
|
||
console.warn('[gstack sidebar] mem-toast closetab failed:', id, err && err.message);
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Driven by every successful /memory poll. Decides whether to surface
|
||
* the toast and which payload to show.
|
||
*/
|
||
function evaluateMemToast(snapshot) {
|
||
if (!snapshot || !Array.isArray(snapshot.tabs)) return;
|
||
const tabs = snapshot.tabs;
|
||
|
||
// Trigger 1: any single tab over 4 GB JS heap. Catches the WebGL/video
|
||
// case before the tab count threshold ever fires.
|
||
const heavyTab = tabs.find((t) => (t.jsHeapUsed || 0) > HEAVY_TAB_HEAP_BYTES);
|
||
if (heavyTab && (heavyTab.jsHeapUsed || 0) > memToastSnooze.heapAbove) {
|
||
showMemToast(
|
||
`Heavy tab: ${fmtBytesShort(heavyTab.jsHeapUsed)} JS heap`,
|
||
'',
|
||
[heavyTab],
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Trigger 2: tab count crossed the hard guardrail (200) and isn't snoozed.
|
||
if (tabs.length >= 200 && tabs.length > memToastSnooze.tabsAbove) {
|
||
const top5 = [...tabs].sort((a, b) => tabRamScore(b) - tabRamScore(a)).slice(0, 5);
|
||
showMemToast(
|
||
`${tabs.length} tabs open — close some?`,
|
||
'',
|
||
top5,
|
||
);
|
||
return;
|
||
}
|
||
|
||
// No trigger: keep toast hidden.
|
||
}
|
||
|
||
function setupMemToastWiring() {
|
||
const close = document.getElementById('mem-toast-close');
|
||
if (close) close.addEventListener('click', dismissMemToast);
|
||
const snooze = document.getElementById('mem-toast-snooze');
|
||
if (snooze) {
|
||
snooze.addEventListener('click', async () => {
|
||
// Snooze logic: bump the thresholds above the current snapshot so the
|
||
// toast won't re-fire until the user has accumulated MORE tabs or one
|
||
// tab has grown ANOTHER 2 GB beyond what we just warned about. Stored
|
||
// in chrome.storage.session so a sidebar reload doesn't lose the
|
||
// snooze (but a Chrome restart does).
|
||
try {
|
||
const resp = await fetch(`${serverUrl}/memory`, {
|
||
headers: { 'Authorization': `Bearer ${serverToken}` },
|
||
signal: AbortSignal.timeout(MEM_POLL_TIMEOUT_MS),
|
||
credentials: 'include',
|
||
});
|
||
if (resp.ok) {
|
||
const snap = await resp.json();
|
||
const tabs = Array.isArray(snap.tabs) ? snap.tabs : [];
|
||
memToastSnooze.tabsAbove = tabs.length + TOAST_SNOOZE_TAB_BUMP;
|
||
const maxHeap = tabs.reduce((m, t) => Math.max(m, t.jsHeapUsed || 0), 0);
|
||
memToastSnooze.heapAbove = maxHeap + TOAST_SNOOZE_HEAP_BUMP;
|
||
await saveSnoozeState();
|
||
}
|
||
} catch (err) {
|
||
console.debug('[gstack sidebar] mem-toast snooze fetch failed:', err && err.message);
|
||
}
|
||
dismissMemToast();
|
||
});
|
||
}
|
||
void loadSnoozeState();
|
||
}
|
||
|
||
// Wire the toast on DOM ready.
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', setupMemToastWiring);
|
||
} else {
|
||
setupMemToastWiring();
|
||
}
|
||
|
||
// ─── Refs Tab ───────────────────────────────────────────────────
|
||
|
||
async function fetchRefs() {
|
||
if (!serverUrl) return;
|
||
try {
|
||
const headers = {};
|
||
if (serverToken) headers['Authorization'] = `Bearer ${serverToken}`;
|
||
const resp = await fetch(`${serverUrl}/refs`, { signal: AbortSignal.timeout(3000), headers });
|
||
if (!resp.ok) return;
|
||
const data = await resp.json();
|
||
|
||
const list = document.getElementById('refs-list');
|
||
const empty = document.getElementById('refs-empty');
|
||
const footer = document.getElementById('refs-footer');
|
||
|
||
if (!data.refs || data.refs.length === 0) {
|
||
empty.style.display = '';
|
||
list.innerHTML = '';
|
||
footer.textContent = '';
|
||
return;
|
||
}
|
||
|
||
empty.style.display = 'none';
|
||
list.innerHTML = data.refs.map(r => `
|
||
<div class="ref-row">
|
||
<span class="ref-id">${escapeHtml(r.ref)}</span>
|
||
<span class="ref-role">${escapeHtml(r.role)}</span>
|
||
<span class="ref-name">"${escapeHtml(r.name)}"</span>
|
||
</div>
|
||
`).join('');
|
||
footer.textContent = `${data.refs.length} refs`;
|
||
} catch (err) {
|
||
console.error('[gstack sidebar] Failed to fetch refs:', err.message);
|
||
}
|
||
}
|
||
|
||
// ─── Inspector Tab ──────────────────────────────────────────────
|
||
|
||
let inspectorPickerActive = false;
|
||
let inspectorData = null; // last inspect result
|
||
let inspectorModifications = []; // tracked style changes
|
||
let inspectorSSE = null;
|
||
|
||
// Inspector DOM refs
|
||
const inspectorPickBtn = document.getElementById('inspector-pick-btn');
|
||
const inspectorSelected = document.getElementById('inspector-selected');
|
||
const inspectorModeBadge = document.getElementById('inspector-mode-badge');
|
||
const inspectorEmpty = document.getElementById('inspector-empty');
|
||
const inspectorLoading = document.getElementById('inspector-loading');
|
||
const inspectorError = document.getElementById('inspector-error');
|
||
const inspectorPanels = document.getElementById('inspector-panels');
|
||
const inspectorBoxmodel = document.getElementById('inspector-boxmodel');
|
||
const inspectorRules = document.getElementById('inspector-rules');
|
||
const inspectorRuleCount = document.getElementById('inspector-rule-count');
|
||
const inspectorComputed = document.getElementById('inspector-computed');
|
||
const inspectorQuickedit = document.getElementById('inspector-quickedit');
|
||
const inspectorSend = document.getElementById('inspector-send');
|
||
const inspectorSendBtn = document.getElementById('inspector-send-btn');
|
||
|
||
// Pick button
|
||
inspectorPickBtn.addEventListener('click', () => {
|
||
if (inspectorPickerActive) {
|
||
inspectorPickerActive = false;
|
||
inspectorPickBtn.classList.remove('active');
|
||
chrome.runtime.sendMessage({ type: 'stopInspector' });
|
||
} else {
|
||
inspectorPickerActive = true;
|
||
inspectorPickBtn.classList.add('active');
|
||
inspectorShowLoading(false); // don't show loading yet, just activate
|
||
chrome.runtime.sendMessage({ type: 'startInspector' }, (result) => {
|
||
if (result?.error) {
|
||
inspectorPickerActive = false;
|
||
inspectorPickBtn.classList.remove('active');
|
||
inspectorShowError(result.error);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
function inspectorShowEmpty() {
|
||
inspectorEmpty.style.display = '';
|
||
inspectorLoading.style.display = 'none';
|
||
inspectorError.style.display = 'none';
|
||
inspectorPanels.style.display = 'none';
|
||
inspectorSend.style.display = 'none';
|
||
}
|
||
|
||
function inspectorShowLoading(show) {
|
||
if (show) {
|
||
inspectorEmpty.style.display = 'none';
|
||
inspectorLoading.style.display = '';
|
||
inspectorError.style.display = 'none';
|
||
inspectorPanels.style.display = 'none';
|
||
} else {
|
||
inspectorLoading.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function inspectorShowError(message) {
|
||
inspectorEmpty.style.display = 'none';
|
||
inspectorLoading.style.display = 'none';
|
||
inspectorError.style.display = '';
|
||
inspectorError.textContent = message;
|
||
inspectorPanels.style.display = 'none';
|
||
}
|
||
|
||
function inspectorShowData(data) {
|
||
inspectorData = data;
|
||
inspectorModifications = [];
|
||
inspectorEmpty.style.display = 'none';
|
||
inspectorLoading.style.display = 'none';
|
||
inspectorError.style.display = 'none';
|
||
inspectorPanels.style.display = '';
|
||
inspectorSend.style.display = '';
|
||
|
||
// Update toolbar
|
||
const tag = data.tagName || '?';
|
||
const cls = data.classes && data.classes.length > 0 ? '.' + data.classes.join('.') : '';
|
||
const idStr = data.id ? '#' + data.id : '';
|
||
inspectorSelected.textContent = `<${tag}>${idStr}${cls}`;
|
||
inspectorSelected.title = data.selector;
|
||
|
||
// Mode badge
|
||
if (data.mode === 'basic') {
|
||
inspectorModeBadge.textContent = 'Basic mode';
|
||
inspectorModeBadge.style.display = '';
|
||
inspectorModeBadge.className = 'inspector-mode-badge basic';
|
||
} else if (data.mode === 'cdp') {
|
||
inspectorModeBadge.textContent = 'CDP';
|
||
inspectorModeBadge.style.display = '';
|
||
inspectorModeBadge.className = 'inspector-mode-badge cdp';
|
||
} else {
|
||
inspectorModeBadge.style.display = 'none';
|
||
}
|
||
|
||
// Render sections
|
||
renderBoxModel(data);
|
||
renderMatchedRules(data);
|
||
renderComputedStyles(data);
|
||
renderQuickEdit(data);
|
||
updateSendButton();
|
||
}
|
||
|
||
// ─── Box Model Rendering ────────────────────────────────────────
|
||
|
||
function renderBoxModel(data) {
|
||
const box = data.basicData?.boxModel || data.boxModel;
|
||
if (!box) { inspectorBoxmodel.innerHTML = '<span class="inspector-no-data">No box model data</span>'; return; }
|
||
|
||
const m = box.margin || {};
|
||
const b = box.border || {};
|
||
const p = box.padding || {};
|
||
const c = box.content || {};
|
||
|
||
inspectorBoxmodel.innerHTML = `
|
||
<div class="boxmodel-margin">
|
||
<span class="boxmodel-label">margin</span>
|
||
<span class="boxmodel-value boxmodel-top">${fmtBoxVal(m.top)}</span>
|
||
<span class="boxmodel-value boxmodel-right">${fmtBoxVal(m.right)}</span>
|
||
<span class="boxmodel-value boxmodel-bottom">${fmtBoxVal(m.bottom)}</span>
|
||
<span class="boxmodel-value boxmodel-left">${fmtBoxVal(m.left)}</span>
|
||
<div class="boxmodel-border">
|
||
<span class="boxmodel-label">border</span>
|
||
<span class="boxmodel-value boxmodel-top">${fmtBoxVal(b.top)}</span>
|
||
<span class="boxmodel-value boxmodel-right">${fmtBoxVal(b.right)}</span>
|
||
<span class="boxmodel-value boxmodel-bottom">${fmtBoxVal(b.bottom)}</span>
|
||
<span class="boxmodel-value boxmodel-left">${fmtBoxVal(b.left)}</span>
|
||
<div class="boxmodel-padding">
|
||
<span class="boxmodel-label">padding</span>
|
||
<span class="boxmodel-value boxmodel-top">${fmtBoxVal(p.top)}</span>
|
||
<span class="boxmodel-value boxmodel-right">${fmtBoxVal(p.right)}</span>
|
||
<span class="boxmodel-value boxmodel-bottom">${fmtBoxVal(p.bottom)}</span>
|
||
<span class="boxmodel-value boxmodel-left">${fmtBoxVal(p.left)}</span>
|
||
<div class="boxmodel-content">
|
||
<span>${Math.round(c.width || 0)} x ${Math.round(c.height || 0)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function fmtBoxVal(v) {
|
||
if (v === undefined || v === null) return '-';
|
||
const n = typeof v === 'number' ? v : parseFloat(v);
|
||
if (isNaN(n) || n === 0) return '0';
|
||
return Math.round(n * 10) / 10;
|
||
}
|
||
|
||
// ─── Matched Rules Rendering ────────────────────────────────────
|
||
|
||
function renderMatchedRules(data) {
|
||
const rules = data.matchedRules || data.basicData?.matchedRules || [];
|
||
inspectorRuleCount.textContent = rules.length > 0 ? `(${rules.length})` : '';
|
||
|
||
if (rules.length === 0) {
|
||
inspectorRules.innerHTML = '<div class="inspector-no-data">No matched rules</div>';
|
||
return;
|
||
}
|
||
|
||
// Separate UA rules from author rules
|
||
const authorRules = [];
|
||
const uaRules = [];
|
||
for (const rule of rules) {
|
||
if (rule.origin === 'user-agent' || rule.isUA) {
|
||
uaRules.push(rule);
|
||
} else {
|
||
authorRules.push(rule);
|
||
}
|
||
}
|
||
|
||
let html = '';
|
||
|
||
// Author rules (expanded)
|
||
for (const rule of authorRules) {
|
||
html += renderRule(rule, false);
|
||
}
|
||
|
||
// UA rules (collapsed by default)
|
||
if (uaRules.length > 0) {
|
||
html += `
|
||
<div class="inspector-ua-rules">
|
||
<button class="inspector-ua-toggle collapsed" aria-expanded="false">
|
||
<span class="inspector-toggle-arrow">▶</span>
|
||
User Agent (${uaRules.length})
|
||
</button>
|
||
<div class="inspector-ua-body collapsed">
|
||
`;
|
||
for (const rule of uaRules) {
|
||
html += renderRule(rule, true);
|
||
}
|
||
html += '</div></div>';
|
||
}
|
||
|
||
inspectorRules.innerHTML = html;
|
||
|
||
// Bind UA toggle
|
||
const uaToggle = inspectorRules.querySelector('.inspector-ua-toggle');
|
||
if (uaToggle) {
|
||
uaToggle.addEventListener('click', () => {
|
||
const body = inspectorRules.querySelector('.inspector-ua-body');
|
||
const isCollapsed = uaToggle.classList.contains('collapsed');
|
||
uaToggle.classList.toggle('collapsed', !isCollapsed);
|
||
uaToggle.setAttribute('aria-expanded', isCollapsed);
|
||
uaToggle.querySelector('.inspector-toggle-arrow').innerHTML = isCollapsed ? '▼' : '▶';
|
||
body.classList.toggle('collapsed', !isCollapsed);
|
||
});
|
||
}
|
||
}
|
||
|
||
function renderRule(rule, isUA) {
|
||
const selectorText = escapeHtml(rule.selector || '');
|
||
const truncatedSelector = selectorText.length > 35 ? selectorText.slice(0, 35) + '...' : selectorText;
|
||
const source = rule.source || '';
|
||
const sourceDisplay = source.includes('/') ? source.split('/').pop() : source;
|
||
const specificity = rule.specificity || '';
|
||
|
||
let propsHtml = '';
|
||
const props = rule.properties || [];
|
||
for (const prop of props) {
|
||
const overridden = prop.overridden ? ' overridden' : '';
|
||
const nameHtml = escapeHtml(prop.name);
|
||
const valText = escapeHtml(prop.value || '');
|
||
const truncatedVal = valText.length > 30 ? valText.slice(0, 30) + '...' : valText;
|
||
const priority = prop.priority === 'important' ? ' <span class="inspector-important">!important</span>' : '';
|
||
propsHtml += `<div class="inspector-prop${overridden}"><span class="inspector-prop-name">${nameHtml}</span>: <span class="inspector-prop-value" title="${valText}">${truncatedVal}</span>${priority};</div>`;
|
||
}
|
||
|
||
return `
|
||
<div class="inspector-rule" role="treeitem">
|
||
<div class="inspector-rule-header">
|
||
<span class="inspector-selector" title="${selectorText}">${truncatedSelector}</span>
|
||
${specificity ? `<span class="inspector-specificity">${escapeHtml(specificity)}</span>` : ''}
|
||
</div>
|
||
<div class="inspector-rule-props">${propsHtml}</div>
|
||
${sourceDisplay ? `<div class="inspector-rule-source">${escapeHtml(sourceDisplay)}</div>` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ─── Computed Styles Rendering ──────────────────────────────────
|
||
|
||
function renderComputedStyles(data) {
|
||
const styles = data.computedStyles || data.basicData?.computedStyles || {};
|
||
const keys = Object.keys(styles);
|
||
|
||
if (keys.length === 0) {
|
||
inspectorComputed.innerHTML = '<div class="inspector-no-data">No computed styles</div>';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
for (const key of keys) {
|
||
const val = styles[key];
|
||
if (!val || val === 'none' || val === 'normal' || val === 'auto' || val === '0px' || val === 'rgba(0, 0, 0, 0)') continue;
|
||
html += `<div class="inspector-computed-row"><span class="inspector-prop-name">${escapeHtml(key)}</span>: <span class="inspector-prop-value">${escapeHtml(val)}</span></div>`;
|
||
}
|
||
|
||
if (!html) {
|
||
html = '<div class="inspector-no-data">All values are defaults</div>';
|
||
}
|
||
|
||
inspectorComputed.innerHTML = html;
|
||
}
|
||
|
||
// ─── Quick Edit ─────────────────────────────────────────────────
|
||
|
||
function renderQuickEdit(data) {
|
||
const selector = data.selector;
|
||
if (!selector) { inspectorQuickedit.innerHTML = ''; return; }
|
||
|
||
// Show common editable properties with current values
|
||
const editableProps = ['color', 'background-color', 'font-size', 'padding', 'margin', 'border', 'display', 'opacity'];
|
||
const computed = data.computedStyles || data.basicData?.computedStyles || {};
|
||
|
||
let html = '<div class="inspector-quickedit-list">';
|
||
for (const prop of editableProps) {
|
||
const val = computed[prop] || '';
|
||
html += `
|
||
<div class="inspector-quickedit-row" data-prop="${escapeHtml(prop)}">
|
||
<span class="inspector-prop-name">${escapeHtml(prop)}</span>:
|
||
<span class="inspector-quickedit-value" data-selector="${escapeHtml(selector)}" data-prop="${escapeHtml(prop)}" tabindex="0" role="button" title="Click to edit">${escapeHtml(val || '(none)')}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
html += '</div>';
|
||
inspectorQuickedit.innerHTML = html;
|
||
|
||
// Bind click-to-edit
|
||
inspectorQuickedit.querySelectorAll('.inspector-quickedit-value').forEach(el => {
|
||
el.addEventListener('click', () => startQuickEdit(el));
|
||
el.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); startQuickEdit(el); }
|
||
});
|
||
});
|
||
}
|
||
|
||
function startQuickEdit(valueEl) {
|
||
if (valueEl.querySelector('input')) return; // already editing
|
||
|
||
const currentVal = valueEl.textContent === '(none)' ? '' : valueEl.textContent;
|
||
const prop = valueEl.dataset.prop;
|
||
const selector = valueEl.dataset.selector;
|
||
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.className = 'inspector-quickedit-input';
|
||
input.value = currentVal;
|
||
valueEl.textContent = '';
|
||
valueEl.appendChild(input);
|
||
input.focus();
|
||
input.select();
|
||
|
||
function commit() {
|
||
const newVal = input.value.trim();
|
||
valueEl.textContent = newVal || '(none)';
|
||
if (newVal && newVal !== currentVal) {
|
||
chrome.runtime.sendMessage({
|
||
type: 'applyStyle',
|
||
selector,
|
||
property: prop,
|
||
value: newVal,
|
||
});
|
||
inspectorModifications.push({ property: prop, value: newVal, selector });
|
||
updateSendButton();
|
||
}
|
||
}
|
||
|
||
function cancel() {
|
||
valueEl.textContent = currentVal || '(none)';
|
||
}
|
||
|
||
input.addEventListener('blur', commit);
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
|
||
if (e.key === 'Escape') { e.preventDefault(); input.removeEventListener('blur', commit); cancel(); }
|
||
});
|
||
}
|
||
|
||
// ─── Send to Agent ──────────────────────────────────────────────
|
||
|
||
function updateSendButton() {
|
||
if (inspectorModifications.length > 0) {
|
||
inspectorSendBtn.textContent = 'Send to Code';
|
||
inspectorSendBtn.title = `${inspectorModifications.length} modification(s) to send`;
|
||
} else {
|
||
inspectorSendBtn.textContent = 'Send to Agent';
|
||
inspectorSendBtn.title = 'Send full inspector data';
|
||
}
|
||
}
|
||
|
||
inspectorSendBtn.addEventListener('click', async () => {
|
||
if (!inspectorData) return;
|
||
|
||
let message;
|
||
if (inspectorModifications.length > 0) {
|
||
// Format modification diff
|
||
const diffs = inspectorModifications.map(m =>
|
||
` ${m.property}: ${m.value} (selector: ${m.selector})`
|
||
).join('\n');
|
||
message = `CSS Inspector modifications:\n\nSelector: ${inspectorData.selector}\n\nChanges:\n${diffs}`;
|
||
|
||
// Include source file info if available
|
||
const rules = inspectorData.matchedRules || inspectorData.basicData?.matchedRules || [];
|
||
const sources = rules.filter(r => r.source && r.source !== 'inline').map(r => r.source);
|
||
if (sources.length > 0) {
|
||
message += `\n\nSource files:\n${[...new Set(sources)].map(s => ` ${s}`).join('\n')}`;
|
||
}
|
||
} else {
|
||
// Send full inspector data
|
||
message = `CSS Inspector data for: ${inspectorData.selector}\n\n${JSON.stringify(inspectorData, null, 2)}`;
|
||
}
|
||
|
||
// Inject into the running claude PTY so the user can ask claude to act
|
||
// on the inspector data. Replaces the old `sidebar-command` route which
|
||
// spawned a one-shot claude -p (sidebar-agent.ts is gone).
|
||
//
|
||
// Pre-scan via /pty-inject-scan before injection (D6, closes #1370).
|
||
// gstackScanForPTYInject is async; gstackInjectToTerminal stays sync.
|
||
const verdict = await window.gstackScanForPTYInject?.(message + '\n', 'inspector-send');
|
||
if (verdict?.verdict === 'BLOCK') {
|
||
console.warn('[gstack sidebar] Inspector send BLOCKED by /pty-inject-scan:', verdict.reasons);
|
||
return;
|
||
}
|
||
if (verdict?.verdict === 'WARN') {
|
||
const confirmed = window.confirm(
|
||
`Inspector send flagged as suspicious (${(verdict.reasons || []).join(', ')}). Inject anyway?`,
|
||
);
|
||
if (!confirmed) return;
|
||
}
|
||
const ok = window.gstackInjectToTerminal?.(message + '\n');
|
||
if (!ok) {
|
||
console.warn('[gstack sidebar] Inspector send needs an active Terminal session.');
|
||
}
|
||
});
|
||
|
||
// ─── Quick Action Helpers (toolbar buttons) ──────────────────────
|
||
|
||
/**
|
||
* "Cleanup" injects a prompt into the running claude PTY. claude takes the
|
||
* prompt, snapshots the page, hides ads/banners/popups, leaves article
|
||
* content. The user watches it happen in the Terminal pane.
|
||
*
|
||
* Replaced the old chat-queue path (sidebar-agent.ts spawning a one-shot
|
||
* claude -p) — we have a live REPL now, so route through that instead.
|
||
*/
|
||
async function runCleanup(...buttons) {
|
||
buttons.forEach(b => b?.classList.add('loading'));
|
||
const cleanupPrompt = [
|
||
'Clean up the active browser page for reading. Run:',
|
||
'$B cleanup --all',
|
||
'then $B snapshot -i, identify any remaining ads, cookie/consent banners,',
|
||
'newsletter popups, login walls, video autoplay, sidebar widgets, share',
|
||
'buttons, floating chat widgets, and hide each via $B eval. Keep the site',
|
||
'header/masthead, headline, article body, images, byline, and date. Also',
|
||
'unlock scrolling if the page is scroll-locked.',
|
||
].join('\n');
|
||
// Pre-scan via /pty-inject-scan before injection (D6, closes #1370).
|
||
// The cleanup prompt is a STATIC template (no page-derived content), so
|
||
// it will always PASS, but we still route it through the scan path so
|
||
// the invariant test in test/extension-pty-inject-invariant.test.ts
|
||
// confirms every call site goes through gstackScanForPTYInject first.
|
||
const verdict = await window.gstackScanForPTYInject?.(cleanupPrompt + '\n', 'cleanup-button');
|
||
if (verdict?.verdict === 'BLOCK') {
|
||
console.warn('[gstack sidebar] Cleanup BLOCKED by /pty-inject-scan:', verdict.reasons);
|
||
setTimeout(() => buttons.forEach(b => b?.classList.remove('loading')), 200);
|
||
return;
|
||
}
|
||
if (verdict?.verdict === 'WARN') {
|
||
const confirmed = window.confirm(
|
||
`Cleanup flagged as suspicious (${(verdict.reasons || []).join(', ')}). Inject anyway?`,
|
||
);
|
||
if (!confirmed) {
|
||
setTimeout(() => buttons.forEach(b => b?.classList.remove('loading')), 200);
|
||
return;
|
||
}
|
||
}
|
||
const sent = window.gstackInjectToTerminal?.(cleanupPrompt + '\n');
|
||
if (!sent) {
|
||
console.warn('[gstack sidebar] Cleanup needs an active Terminal session.');
|
||
}
|
||
setTimeout(() => buttons.forEach(b => b?.classList.remove('loading')), 1200);
|
||
}
|
||
|
||
async function runScreenshot(...buttons) {
|
||
if (!serverUrl || !serverToken) return;
|
||
buttons.forEach(b => b?.classList.add('loading'));
|
||
try {
|
||
const resp = await fetch(`${serverUrl}/command`, {
|
||
method: 'POST',
|
||
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ command: 'screenshot', args: [] }),
|
||
signal: AbortSignal.timeout(15000),
|
||
});
|
||
const text = await resp.text();
|
||
if (!resp.ok) {
|
||
console.warn('[gstack sidebar] Screenshot failed:', text);
|
||
} else {
|
||
console.log('[gstack sidebar] Screenshot:', text);
|
||
}
|
||
} catch (err) {
|
||
console.error('[gstack sidebar] Screenshot error:', err.message);
|
||
} finally {
|
||
buttons.forEach(b => b?.classList.remove('loading'));
|
||
}
|
||
}
|
||
|
||
// ─── Wire up all cleanup/screenshot buttons (inspector + chat toolbar) ──
|
||
|
||
const inspectorCleanupBtn = document.getElementById('inspector-cleanup-btn');
|
||
const inspectorScreenshotBtn = document.getElementById('inspector-screenshot-btn');
|
||
const chatCleanupBtn = document.getElementById('chat-cleanup-btn');
|
||
const chatScreenshotBtn = document.getElementById('chat-screenshot-btn');
|
||
|
||
if (inspectorCleanupBtn) inspectorCleanupBtn.addEventListener('click', () => runCleanup(inspectorCleanupBtn, chatCleanupBtn));
|
||
if (inspectorScreenshotBtn) inspectorScreenshotBtn.addEventListener('click', () => runScreenshot(inspectorScreenshotBtn, chatScreenshotBtn));
|
||
if (chatCleanupBtn) chatCleanupBtn.addEventListener('click', () => runCleanup(chatCleanupBtn, inspectorCleanupBtn));
|
||
if (chatScreenshotBtn) chatScreenshotBtn.addEventListener('click', () => runScreenshot(chatScreenshotBtn, inspectorScreenshotBtn));
|
||
|
||
// ─── Section Toggles ────────────────────────────────────────────
|
||
|
||
document.querySelectorAll('.inspector-section-toggle').forEach(toggle => {
|
||
toggle.addEventListener('click', () => {
|
||
const section = toggle.dataset.section;
|
||
const body = document.getElementById(`inspector-${section}`);
|
||
const isCollapsed = toggle.classList.contains('collapsed');
|
||
|
||
toggle.classList.toggle('collapsed', !isCollapsed);
|
||
toggle.setAttribute('aria-expanded', isCollapsed);
|
||
toggle.querySelector('.inspector-toggle-arrow').innerHTML = isCollapsed ? '▼' : '▶';
|
||
body.classList.toggle('collapsed', !isCollapsed);
|
||
});
|
||
});
|
||
|
||
// ─── Inspector SSE ──────────────────────────────────────────────
|
||
|
||
async function connectInspectorSSE() {
|
||
if (!serverUrl || !serverToken) return;
|
||
if (inspectorSSE) { inspectorSSE.close(); inspectorSSE = null; }
|
||
|
||
// Same session-cookie pattern as connectSSE. ?token= is gone (see N1
|
||
// in the v1.6.0.0 security wave plan).
|
||
await ensureSseSessionCookie();
|
||
const url = `${serverUrl}/inspector/events?_=${Date.now()}`;
|
||
|
||
try {
|
||
inspectorSSE = new EventSource(url, { withCredentials: true });
|
||
|
||
inspectorSSE.addEventListener('inspectResult', (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
inspectorShowData(data);
|
||
} catch (err) {
|
||
console.error('[gstack sidebar] Failed to parse inspectResult:', err.message);
|
||
}
|
||
});
|
||
|
||
inspectorSSE.addEventListener('error', () => {
|
||
// SSE connection failed — inspector works without it (basic mode)
|
||
if (inspectorSSE) { inspectorSSE.close(); inspectorSSE = null; }
|
||
});
|
||
} catch (err) {
|
||
console.debug('[gstack sidebar] Inspector SSE not available:', err.message);
|
||
}
|
||
}
|
||
|
||
// ─── Server Discovery ───────────────────────────────────────────
|
||
|
||
function setActionButtonsEnabled(enabled) {
|
||
const btns = document.querySelectorAll('.quick-action-btn, .inspector-action-btn');
|
||
btns.forEach(btn => {
|
||
btn.disabled = !enabled;
|
||
btn.classList.toggle('disabled', !enabled);
|
||
});
|
||
}
|
||
|
||
function updateConnection(url, token) {
|
||
const wasConnected = !!serverUrl;
|
||
serverUrl = url;
|
||
serverToken = token || null;
|
||
// Expose for sidepanel-terminal.js (PTY surface). The terminal pane needs
|
||
// the bootstrap token to POST /pty-session and the port to derive the WS
|
||
// URL. We never expose the PTY token — it lives in an HttpOnly cookie.
|
||
if (url) {
|
||
try { window.gstackServerPort = parseInt(new URL(url).port, 10); } catch {}
|
||
window.gstackAuthToken = token || null;
|
||
} else {
|
||
window.gstackServerPort = null;
|
||
window.gstackAuthToken = null;
|
||
}
|
||
if (url) {
|
||
document.getElementById('footer-dot').className = 'dot connected';
|
||
const port = new URL(url).port;
|
||
document.getElementById('footer-port').textContent = `:${port}`;
|
||
setConnState('connected');
|
||
setActionButtonsEnabled(true);
|
||
// Tell the active tab's content script the sidebar is open — this hides
|
||
// the welcome page arrow hint. Only fires on actual sidebar connection.
|
||
chrome.runtime.sendMessage({ type: 'sidebarOpened' }).catch(() => {});
|
||
connectSSE();
|
||
connectInspectorSSE();
|
||
startMemPolling();
|
||
} else {
|
||
document.getElementById('footer-dot').className = 'dot';
|
||
document.getElementById('footer-port').textContent = '';
|
||
const memEl = document.getElementById('footer-mem');
|
||
if (memEl) {
|
||
memEl.textContent = '';
|
||
memEl.classList.remove('warn', 'bad');
|
||
}
|
||
stopMemPolling();
|
||
setActionButtonsEnabled(false);
|
||
if (wasConnected) startReconnect();
|
||
}
|
||
}
|
||
|
||
// ─── Port Configuration ─────────────────────────────────────────
|
||
|
||
const portLabel = document.getElementById('footer-port');
|
||
const portInput = document.getElementById('port-input');
|
||
|
||
portLabel.addEventListener('click', () => {
|
||
portLabel.style.display = 'none';
|
||
portInput.style.display = '';
|
||
chrome.runtime.sendMessage({ type: 'getPort' }, (resp) => {
|
||
portInput.value = resp?.port || '';
|
||
portInput.focus();
|
||
portInput.select();
|
||
});
|
||
});
|
||
|
||
function savePort() {
|
||
const port = parseInt(portInput.value, 10);
|
||
if (port > 0 && port < 65536) {
|
||
chrome.runtime.sendMessage({ type: 'setPort', port });
|
||
}
|
||
portInput.style.display = 'none';
|
||
portLabel.style.display = '';
|
||
}
|
||
portInput.addEventListener('blur', savePort);
|
||
portInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') savePort();
|
||
if (e.key === 'Escape') { portInput.style.display = 'none'; portLabel.style.display = ''; }
|
||
});
|
||
|
||
// ─── Reconnect / Copy Buttons ────────────────────────────────────
|
||
|
||
document.getElementById('conn-reconnect').addEventListener('click', () => {
|
||
reconnectAttempts = 0;
|
||
startReconnect();
|
||
});
|
||
|
||
document.getElementById('conn-copy').addEventListener('click', () => {
|
||
navigator.clipboard.writeText('/open-gstack-browser').then(() => {
|
||
const btn = document.getElementById('conn-copy');
|
||
btn.textContent = 'copied!';
|
||
setTimeout(() => { btn.textContent = '/open-gstack-browser'; }, 2000);
|
||
});
|
||
});
|
||
|
||
// Try to connect immediately, retry every 2s until connected.
|
||
// Show exactly what's happening at each step so the user is never
|
||
// staring at a blank "Connecting..." with no info.
|
||
let connectAttempts = 0;
|
||
function setLoadingStatus(msg, debug) {
|
||
// The status line lives inside the Terminal bootstrap card now —
|
||
// sidepanel-terminal.js owns it. We only update the debug pre block,
|
||
// and trust the terminal pane to surface the human-readable status.
|
||
const dbg = document.getElementById('loading-debug');
|
||
if (dbg && debug !== undefined) dbg.textContent = debug;
|
||
}
|
||
|
||
async function tryConnect() {
|
||
connectAttempts++;
|
||
setLoadingStatus(
|
||
`Looking for browse server... (attempt ${connectAttempts})`,
|
||
`Asking background.js for server port...`
|
||
);
|
||
|
||
// Step 1: Ask background for the port
|
||
const resp = await new Promise(resolve => {
|
||
chrome.runtime.sendMessage({ type: 'getPort' }, (r) => {
|
||
if (chrome.runtime.lastError) {
|
||
resolve({ error: chrome.runtime.lastError.message });
|
||
} else {
|
||
resolve(r || {});
|
||
}
|
||
});
|
||
});
|
||
|
||
if (resp.error) {
|
||
setLoadingStatus(
|
||
`Extension error (attempt ${connectAttempts})`,
|
||
`chrome.runtime.sendMessage failed:\n${resp.error}`
|
||
);
|
||
setTimeout(tryConnect, 2000);
|
||
return;
|
||
}
|
||
|
||
const port = resp.port || 34567;
|
||
|
||
// Step 2: If background says connected + has token, use that
|
||
if (resp.port && resp.connected && resp.token) {
|
||
setLoadingStatus(
|
||
`Server found on port ${port}, connecting...`,
|
||
`token: yes\nStarting SSE + chat polling...`
|
||
);
|
||
updateConnection(`http://127.0.0.1:${port}`, resp.token);
|
||
return;
|
||
}
|
||
|
||
// Step 3: Background not connected yet. Try hitting /health directly.
|
||
// This bypasses the background.js health poll timing gap.
|
||
setLoadingStatus(
|
||
`Checking server directly... (attempt ${connectAttempts})`,
|
||
`port: ${port}\nbackground connected: ${resp.connected || false}\nTrying GET http://127.0.0.1:${port}/health ...`
|
||
);
|
||
|
||
try {
|
||
const healthResp = await fetch(`http://127.0.0.1:${port}/health`, {
|
||
signal: AbortSignal.timeout(2000)
|
||
});
|
||
if (healthResp.ok) {
|
||
const data = await healthResp.json();
|
||
if (data.status === 'healthy' && data.token) {
|
||
setLoadingStatus(
|
||
`Server healthy on port ${port}, connecting...`,
|
||
`token: yes (from /health)\nStarting SSE + activity feed...`
|
||
);
|
||
updateConnection(`http://127.0.0.1:${port}`, data.token);
|
||
// The SEC shield used to drive off /health.security via the chat
|
||
// path's classifier; with the chat path ripped, the indicator is
|
||
// not driven yet. Leaving the shield element hidden by default.
|
||
return;
|
||
}
|
||
setLoadingStatus(
|
||
`Server responded but not healthy (attempt ${connectAttempts})`,
|
||
`status: ${data.status}\ntoken: ${data.token ? 'yes' : 'no'}`
|
||
);
|
||
} else {
|
||
setLoadingStatus(
|
||
`Server returned ${healthResp.status} (attempt ${connectAttempts})`,
|
||
`GET /health → ${healthResp.status} ${healthResp.statusText}`
|
||
);
|
||
}
|
||
} catch (e) {
|
||
setLoadingStatus(
|
||
`Server not reachable on port ${port} (attempt ${connectAttempts})`,
|
||
`GET /health failed: ${e.message}\n\nThe browse server may still be starting.\nRun /open-gstack-browser in Claude Code.`
|
||
);
|
||
}
|
||
|
||
setTimeout(tryConnect, 2000);
|
||
}
|
||
tryConnect();
|
||
|
||
// ─── Message Listener ───────────────────────────────────────────
|
||
|
||
chrome.runtime.onMessage.addListener((msg) => {
|
||
if (msg.type === 'health') {
|
||
if (msg.data) {
|
||
const url = `http://127.0.0.1:${msg.data.port || 34567}`;
|
||
// Request token via targeted sendResponse (not broadcast) to limit exposure
|
||
chrome.runtime.sendMessage({ type: 'getToken' }, (resp) => {
|
||
updateConnection(url, resp?.token || null);
|
||
});
|
||
} else {
|
||
updateConnection(null);
|
||
}
|
||
}
|
||
if (msg.type === 'refs') {
|
||
if (document.querySelector('.tab[data-tab="refs"].active')) {
|
||
fetchRefs();
|
||
}
|
||
}
|
||
if (msg.type === 'inspectResult') {
|
||
inspectorPickerActive = false;
|
||
inspectorPickBtn.classList.remove('active');
|
||
if (msg.data) {
|
||
inspectorShowData(msg.data);
|
||
} else {
|
||
inspectorShowError('Element not found, try picking again');
|
||
}
|
||
}
|
||
if (msg.type === 'pickerCancelled') {
|
||
inspectorPickerActive = false;
|
||
inspectorPickBtn.classList.remove('active');
|
||
}
|
||
// browserTabState: full snapshot of all open tabs + the active one,
|
||
// pushed by background.js on chrome.tabs events. We forward it as a
|
||
// custom event so sidepanel-terminal.js can relay to terminal-agent.ts.
|
||
// Result: claude's <stateDir>/tabs.json + active-tab.json stay live.
|
||
if (msg.type === 'browserTabState') {
|
||
document.dispatchEvent(new CustomEvent('gstack:tab-state', {
|
||
detail: { active: msg.active, tabs: msg.tabs, reason: msg.reason },
|
||
}));
|
||
}
|
||
});
|
||
|
||
// ─── v1.44 pagehide: explicit PTY dispose on sidebar close ──────────
|
||
//
|
||
// Codex T3 of the eng review: WS close codes alone can't distinguish
|
||
// "intentional close" (sidebar closed, browser quit, extension reload)
|
||
// from "transient blip" (wifi hiccup) reliably — Chrome routes the
|
||
// former through code 1001 (going-away) and the latter through 1006
|
||
// (abnormal), but neither is a load-bearing contract across browsers
|
||
// and extension lifecycles.
|
||
//
|
||
// pagehide fires reliably for tab close, panel close, extension reload,
|
||
// and navigation-away. We use it to fire-and-forget a /pty-dispose POST
|
||
// so the server can synchronously dispose the PtySession instead of
|
||
// waiting for the 60s detach window (Commit 3) to time out. Zombie
|
||
// claude processes lingering for 60s on every browser quit was the
|
||
// codex-flagged failure mode.
|
||
//
|
||
// sendBeacon is the only fetch primitive that survives a closing page —
|
||
// it doesn't accept custom headers, which is why the server's
|
||
// /pty-dispose route accepts the auth token in the BODY (see
|
||
// server-pty-lease-routes.test.ts test 4).
|
||
window.addEventListener('pagehide', () => {
|
||
const sessionId = window.gstackPtySession;
|
||
const authToken = window.gstackAuthToken;
|
||
const port = window.gstackServerPort;
|
||
if (!sessionId || !authToken || !port) return;
|
||
try {
|
||
const blob = new Blob([JSON.stringify({ sessionId, authToken })], {
|
||
type: 'application/json',
|
||
});
|
||
navigator.sendBeacon(`http://127.0.0.1:${port}/pty-dispose`, blob);
|
||
} catch {
|
||
// Best-effort — the 60s detach timer will catch any session we miss.
|
||
}
|
||
});
|