@pa1nd/horse-browser 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,541 @@
1
+ // monitor.js — Horse Browser console.
2
+ //
3
+ // This page is a *second* CDP client on :9223 (browser-harness is the first).
4
+ // Modern Chrome allows multiple flat sessions per target, so we can screencast a
5
+ // tab while an agent drives it.
6
+ //
7
+ // THE WALL IS A FIXED SET OF SLOTS. The grid is N² slots (2×2 / 3×3), row-major:
8
+ // slot 0 = top-left … slot N²-1 = bottom-right. A tab, once placed in a slot,
9
+ // STAYS in that slot — activity inside a shown tab never reorders the wall, it
10
+ // only lights the slot's "active" outline in place. The order changes only when
11
+ // MEMBERSHIP changes, governed by computeSlots():
12
+ // • a closed tab frees its slot; the hottest waiting tab fills it (no guard)
13
+ // • a hotter waiting tab evicts the COLDEST slot — but only once that slot has
14
+ // been idle longer than HOT_MS, so a wall full of busy agents stays frozen
15
+ // and changes at most once something idles.
16
+ // The sidebar mirrors this: slot order on top (numbered, hard-linked to cells),
17
+ // a divider, then the bench (everything not shown) ranked by recency.
18
+
19
+ const CDP = "http://127.0.0.1:9223";
20
+
21
+ // How long a slot stays "hot" after its last activity: it glows, and it is
22
+ // PROTECTED from eviction for this long. Tunable — we may try 5s / 10s / 15s.
23
+ const HOT_MS = 15000;
24
+
25
+ const GROUP_COLORS = {
26
+ grey: "#9aa0a6", blue: "#8ab4f8", red: "#f28b82", yellow: "#fdd663",
27
+ green: "#81c995", pink: "#ff8bcb", purple: "#c58af9", cyan: "#78d9ec", orange: "#fcad70",
28
+ };
29
+
30
+ const grid = document.getElementById("grid");
31
+ const emptyEl = document.getElementById("empty");
32
+ const gridSel = document.getElementById("gridsel");
33
+ const tabListEl = document.getElementById("tablist");
34
+ const statTabs = document.getElementById("stat-tabs");
35
+ const collapseBtn = document.getElementById("collapse");
36
+
37
+ let ws, msgId = 0;
38
+ const pending = new Map(); // request id → resolver
39
+ const sessionHandlers = new Map(); // CDP sessionId → frame handler
40
+ const panes = new Map(); // targetId → pane (only for tabs currently on the wall)
41
+ let slots = []; // slot index → targetId | null (the persistent wall)
42
+
43
+ // Every CDP request times out. Right after a browser restart the freshly-restored
44
+ // renderer sometimes never answers an attach/screencast call; without a timeout
45
+ // that promise hangs forever, and because reconcile() holds a non-reentrant lock
46
+ // across `await watch()`, the WHOLE monitor deadlocks (empty sidebar + grid until
47
+ // a manual reload). On timeout we resolve to a benign error shape so callers that
48
+ // read `.result` simply treat it as "no data" and move on.
49
+ function send(method, params, sessionId, timeoutMs = 8000) {
50
+ return new Promise((resolve) => {
51
+ const id = ++msgId;
52
+ let done = false;
53
+ const finish = (v) => { if (done) return; done = true; pending.delete(id); resolve(v); };
54
+ pending.set(id, finish);
55
+ const timer = setTimeout(() => finish({ error: { message: "timeout", method } }), timeoutMs);
56
+ // wrap so clearing the timer happens whenever the real response arrives
57
+ const wrapped = (v) => { clearTimeout(timer); finish(v); };
58
+ pending.set(id, wrapped);
59
+ try { ws.send(JSON.stringify({ id, method, params: params || {}, sessionId })); }
60
+ catch (e) { clearTimeout(timer); finish({ error: { message: String(e), method } }); }
61
+ });
62
+ }
63
+
64
+ async function connect() {
65
+ const ver = await (await fetch(CDP + "/json/version")).json();
66
+ ws = new WebSocket(ver.webSocketDebuggerUrl);
67
+ ws.onmessage = (e) => {
68
+ const m = JSON.parse(e.data);
69
+ if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); return; }
70
+ if (m.sessionId && sessionHandlers.has(m.sessionId)) sessionHandlers.get(m.sessionId)(m);
71
+ };
72
+ await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
73
+ }
74
+
75
+ // ── per-tab activity tracking ────────────────────────────────────────────────
76
+ // lastActivity[tabId] = ms of the most recent ACTION we can observe: a navigation
77
+ // or load (chrome.tabs.onUpdated url/status — fires for background tabs too, so it
78
+ // catches agent-driven navigations), a focus (onActivated), or the tab's own
79
+ // lastAccessed as a seed for history before the monitor opened. We deliberately do
80
+ // NOT count screencast repaints or passive title/favicon churn — those keep a tab
81
+ // painting without any real action and would peg every visible tab to "active".
82
+ const lastActivity = new Map();
83
+ function noteActivity(tabId, ts) {
84
+ if (tabId == null) return;
85
+ if (ts > (lastActivity.get(tabId) || 0)) lastActivity.set(tabId, ts);
86
+ }
87
+ chrome.tabs.onUpdated.addListener((tabId, ci) => {
88
+ if (ci.url || ci.status) noteActivity(tabId, Date.now()); // navigation / (re)load only
89
+ });
90
+ chrome.tabs.onActivated.addListener(({ tabId }) => noteActivity(tabId, Date.now()));
91
+ chrome.tabs.onCreated.addListener((t) => noteActivity(t.id, Date.now()));
92
+
93
+ function ago(ts) {
94
+ if (!ts) return "—";
95
+ const s = Math.floor((Date.now() - ts) / 1000);
96
+ if ((Date.now() - ts) < HOT_MS) return "active";
97
+ if (s < 60) return s + "s";
98
+ const m = Math.floor(s / 60); if (m < 60) return m + "m";
99
+ const h = Math.floor(m / 60); if (h < 24) return h + "h";
100
+ return Math.floor(h / 24) + "d";
101
+ }
102
+
103
+ // Every real web tab (http/https/file), with its observed last-activity time.
104
+ async function discover() {
105
+ const groups = await chrome.tabGroups.query({});
106
+ const gById = new Map(groups.map((g) => [g.id, g]));
107
+ const tabs = await chrome.tabs.query({});
108
+ const targets = await chrome.debugger.getTargets();
109
+ const tgtByTab = new Map(targets.filter((t) => t.tabId).map((t) => [t.tabId, t.id]));
110
+ const self = location.href;
111
+ const out = tabs
112
+ .filter((t) => tgtByTab.has(t.id) && /^(https?|file):/.test(t.url || "") && t.url !== self)
113
+ .map((t) => {
114
+ const g = gById.get(t.groupId);
115
+ let host = ""; try { host = new URL(t.url).hostname.replace(/^www\./, ""); } catch {}
116
+ if (!lastActivity.has(t.id)) lastActivity.set(t.id, t.lastAccessed || Date.now());
117
+ else if (t.lastAccessed && t.lastAccessed > lastActivity.get(t.id)) lastActivity.set(t.id, t.lastAccessed);
118
+ return {
119
+ tabId: t.id, targetId: tgtByTab.get(t.id), title: t.title, url: t.url, host,
120
+ favIconUrl: t.favIconUrl || "", active: !!t.active, index: t.index,
121
+ groupId: (t.groupId != null && t.groupId >= 0) ? t.groupId : -1,
122
+ groupTitle: g ? g.title : "",
123
+ color: g ? (GROUP_COLORS[g.color] || "#9aa0a6") : "#5b6470",
124
+ lastActivity: lastActivity.get(t.id),
125
+ };
126
+ });
127
+ // prune activity for tabs that no longer exist
128
+ const live = new Set(out.map((a) => a.tabId));
129
+ for (const k of [...lastActivity.keys()]) if (!live.has(k)) lastActivity.delete(k);
130
+ return out;
131
+ }
132
+
133
+ // <computeSlots> — PURE slot assignment. No DOM, no CDP, no globals (except it
134
+ // reads HOT_MS via the `guard` arg). Unit-tested in Node by slicing this region
135
+ // out of the file and eval'ing it, so the test exercises the real shipped logic.
136
+ // prev : previous slots array (targetId|null per cell)
137
+ // agents : [{ targetId, lastActivity }, ...] (the live tab set)
138
+ // cap : number of slots (N²)
139
+ // now : Date.now()
140
+ // guard : ms a slot must be idle before it can be evicted (HOT_MS)
141
+ // → new slots array of length `cap`.
142
+ function computeSlots(prev, agents, cap, now, guard) {
143
+ const byId = new Map(agents.map((a) => [a.targetId, a]));
144
+ // resize: keep slot→tab bindings for indices that still exist; pad/truncate.
145
+ const slots = new Array(cap).fill(null);
146
+ for (let i = 0; i < Math.min(cap, prev.length); i++) slots[i] = prev[i];
147
+ // prune closed tabs and any duplicate (safety) — a tab lives in at most one slot.
148
+ const seen = new Set();
149
+ for (let i = 0; i < cap; i++) {
150
+ const id = slots[i];
151
+ if (id == null) continue;
152
+ if (!byId.has(id) || seen.has(id)) slots[i] = null;
153
+ else seen.add(id);
154
+ }
155
+ const benchSorted = () => {
156
+ const placed = new Set();
157
+ for (const id of slots) if (id != null) placed.add(id);
158
+ return agents.filter((a) => !placed.has(a.targetId))
159
+ .sort((x, y) => y.lastActivity - x.lastActivity);
160
+ };
161
+ // 1) fill EMPTY slots, lowest index first, with the hottest bench tab — no guard.
162
+ for (let i = 0; i < cap; i++) {
163
+ if (slots[i] != null) continue;
164
+ const b = benchSorted();
165
+ if (!b.length) break;
166
+ slots[i] = b[0].targetId;
167
+ }
168
+ // 2) GUARDED swaps: the hottest bench tab evicts the COLDEST slot iff it is
169
+ // strictly hotter AND that slot has been idle longer than `guard`. Repeats
170
+ // until no warranted swap (bounded by cap — converges, can't oscillate:
171
+ // an evicted tab is the coldest, so it can't be hotter than a remaining slot).
172
+ for (let loop = 0; loop < cap; loop++) {
173
+ const b = benchSorted();
174
+ if (!b.length) break;
175
+ const hottest = b[0];
176
+ let coldIdx = -1, coldAct = Infinity;
177
+ for (let i = 0; i < cap; i++) {
178
+ const id = slots[i];
179
+ if (id == null) continue;
180
+ const act = byId.get(id).lastActivity;
181
+ if (act < coldAct) { coldAct = act; coldIdx = i; }
182
+ }
183
+ if (coldIdx < 0) break;
184
+ if (hottest.lastActivity > coldAct && (now - coldAct) > guard) {
185
+ slots[coldIdx] = hottest.targetId; // evict cold; newcomer inherits the exact slot
186
+ } else break;
187
+ }
188
+ return slots;
189
+ }
190
+ // </computeSlots>
191
+
192
+ // The hottest bench tab that WANTS a slot but is blocked by the guard (its target
193
+ // slot is still hot). Marked "standing by" in the sidebar. null if none waiting.
194
+ function standbyId(slots, agents, byId, now, guard) {
195
+ const placed = new Set();
196
+ for (const id of slots) if (id != null) placed.add(id);
197
+ const bench = agents.filter((a) => !placed.has(a.targetId))
198
+ .sort((x, y) => y.lastActivity - x.lastActivity);
199
+ if (!bench.length) return null;
200
+ let coldAct = Infinity;
201
+ for (const id of slots) if (id != null) coldAct = Math.min(coldAct, byId.get(id).lastActivity);
202
+ const h = bench[0];
203
+ if (h.lastActivity > coldAct && (now - coldAct) <= guard) return h.targetId;
204
+ return null;
205
+ }
206
+
207
+ // ── sidebar: tabs grouped by session — Chrome-style coloured groups ────────────
208
+ // (emoji + code header, a coloured spine, favicon rows). The wall is computed
209
+ // separately; this is display-only. Reorders are minimal so rows don't re-animate.
210
+ const groupEls = new Map(); // groupId → { wrap, name, head, tabsEl }
211
+ const tabEls = new Map(); // targetId → row element
212
+
213
+ function makeGroup() {
214
+ const wrap = document.createElement("div");
215
+ wrap.className = "sb-group";
216
+ wrap.innerHTML =
217
+ '<div class="sb-ghead"><span class="sb-gname"></span>' +
218
+ '<svg class="sb-chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10l4-4 4 4"/></svg></div>' +
219
+ '<div class="sb-gtabs"></div>';
220
+ return { wrap, name: wrap.querySelector(".sb-gname"), head: wrap.querySelector(".sb-ghead"), tabsEl: wrap.querySelector(".sb-gtabs") };
221
+ }
222
+
223
+ function makeEntry(key) {
224
+ const el = document.createElement("div");
225
+ el.className = "tab";
226
+ el.dataset.key = key;
227
+ el.innerHTML =
228
+ '<span class="tab-ico"><img alt="" /></span>' +
229
+ '<span class="tab-title"></span>' +
230
+ '<span class="slot-no"></span>' +
231
+ '<span class="tab-dot"></span>';
232
+ el.querySelector("img").addEventListener("error", (e) => {
233
+ e.target.removeAttribute("src"); e.target.closest(".tab-ico").classList.remove("has-ico");
234
+ });
235
+ return el;
236
+ }
237
+
238
+ function setIco(el, favIconUrl) {
239
+ const ico = el.querySelector(".tab-ico");
240
+ const img = ico.querySelector("img");
241
+ if (favIconUrl && /^(https?:|data:)/.test(favIconUrl)) {
242
+ if (img.getAttribute("src") !== favIconUrl) img.src = favIconUrl;
243
+ ico.classList.add("has-ico");
244
+ } else { img.removeAttribute("src"); ico.classList.remove("has-ico"); }
245
+ }
246
+
247
+ function renderSidebar(slots, agents, byId, cap) {
248
+ try {
249
+ // which wall cell each tab sits in (1-based, matches the pane's slot badge)
250
+ const slotNo = new Map();
251
+ slots.forEach((id, i) => { if (id != null) slotNo.set(id, i + 1); });
252
+ // bucket tabs by their session tab-group
253
+ const groups = new Map(); // gid → { gid, title, color, tabs, minIndex }
254
+ for (const a of agents) {
255
+ if (!groups.has(a.groupId)) groups.set(a.groupId, { gid: a.groupId, title: a.groupTitle || "", color: a.color, tabs: [], minIndex: a.index });
256
+ const g = groups.get(a.groupId); g.tabs.push(a); if (a.index < g.minIndex) g.minIndex = a.index;
257
+ }
258
+ const glist = [...groups.values()];
259
+ // tab-strip order, within a group and across groups, so it matches Chrome's own sidebar;
260
+ // ungrouped (-1) sinks to the bottom
261
+ for (const g of glist) g.tabs.sort((x, y) => x.index - y.index);
262
+ glist.sort((A, B) => (A.gid === -1) - (B.gid === -1) || A.minIndex - B.minIndex);
263
+
264
+ // drop groups / rows that vanished
265
+ const liveGids = new Set(glist.map((g) => g.gid));
266
+ for (const [gid, ge] of [...groupEls]) if (!liveGids.has(gid)) { ge.wrap.remove(); groupEls.delete(gid); }
267
+ const liveTids = new Set(agents.map((a) => a.targetId));
268
+ for (const [tid, el] of [...tabEls]) if (!liveTids.has(tid)) { el.remove(); tabEls.delete(tid); }
269
+
270
+ glist.forEach((g) => {
271
+ let ge = groupEls.get(g.gid);
272
+ if (!ge) { ge = makeGroup(); groupEls.set(g.gid, ge); }
273
+ ge.wrap.classList.toggle("ungrouped", g.gid === -1);
274
+ ge.name.textContent = g.title;
275
+ ge.head.style.background = g.color;
276
+ ge.tabsEl.style.boxShadow = "inset 2px 0 0 " + g.color;
277
+ g.tabs.forEach((a) => {
278
+ let el = tabEls.get(a.targetId);
279
+ if (!el) { el = makeEntry(a.targetId); tabEls.set(a.targetId, el); }
280
+ if (el.parentElement !== ge.tabsEl) ge.tabsEl.appendChild(el);
281
+ el.style.setProperty("--c", a.color);
282
+ setIco(el, a.favIconUrl);
283
+ el.querySelector(".tab-title").textContent = a.title || a.host || a.url;
284
+ const n = slotNo.get(a.targetId);
285
+ el.classList.toggle("on-wall", n != null);
286
+ el.querySelector(".slot-no").textContent = n != null ? String(n) : "";
287
+ el.classList.toggle("active", (Date.now() - a.lastActivity) < HOT_MS);
288
+ });
289
+ // minimal reorder of rows within the group
290
+ let node = ge.tabsEl.firstChild;
291
+ for (const a of g.tabs) {
292
+ const el = tabEls.get(a.targetId);
293
+ if (node === el) node = node.nextSibling;
294
+ else ge.tabsEl.insertBefore(el, node);
295
+ }
296
+ });
297
+ // minimal reorder of the groups themselves
298
+ let gnode = tabListEl.firstChild;
299
+ for (const g of glist) {
300
+ const ge = groupEls.get(g.gid);
301
+ if (gnode === ge.wrap) gnode = gnode.nextSibling;
302
+ else tabListEl.insertBefore(ge.wrap, gnode);
303
+ }
304
+ statTabs.textContent = agents.length;
305
+ } catch (e) { /* a sidebar hiccup must never break the wall reconcile */ }
306
+ }
307
+
308
+ // ── screencast panes ─────────────────────────────────────────────────────────
309
+ function draw(pane, b64) {
310
+ const img = pane.img;
311
+ img.onload = () => {
312
+ const c = pane.canvas;
313
+ if (c.width !== img.naturalWidth) { c.width = img.naturalWidth; c.height = img.naturalHeight; }
314
+ pane.ctx.drawImage(img, 0, 0);
315
+ };
316
+ img.src = "data:image/jpeg;base64," + b64;
317
+ }
318
+
319
+ async function forceCapture(pane) {
320
+ if (!pane.sid) return;
321
+ try {
322
+ // fromSurface:true captures even a backgrounded tab. startScreencast only
323
+ // streams frames for the VISIBLE tab, so background panes (and every tab right
324
+ // after a browser restart) are painted by this still capture, not the stream.
325
+ const r = await send("Page.captureScreenshot",
326
+ { format: "jpeg", quality: 50, fromSurface: true }, pane.sid);
327
+ if (r.result && r.result.data) draw(pane, r.result.data);
328
+ } catch {}
329
+ }
330
+
331
+ function makePane(info) {
332
+ const el = document.createElement("div");
333
+ el.className = "pane";
334
+ el.dataset.tid = info.targetId; // lets reconcile detect & drop orphaned duplicate panes
335
+ el.style.setProperty("--accent", info.color);
336
+ el.innerHTML = '<canvas></canvas><div class="slot-badge"></div>';
337
+ el.addEventListener("click", async () => {
338
+ await chrome.tabs.update(info.tabId, { active: true });
339
+ const tab = await chrome.tabs.get(info.tabId);
340
+ chrome.windows.update(tab.windowId, { focused: true });
341
+ });
342
+ grid.appendChild(el);
343
+ const canvas = el.querySelector("canvas");
344
+ return { el, badge: el.querySelector(".slot-badge"),
345
+ canvas, ctx: canvas.getContext("2d"), img: new Image(), lastFrame: 0 };
346
+ }
347
+
348
+ async function watch(info) {
349
+ // Short timeout (not the default 8s): right after a browser restart the target
350
+ // subsystem isn't ready to attach for ~1–3s and simply doesn't answer. Failing
351
+ // fast lets the 1s poll retry and succeed once the browser settles, instead of
352
+ // every pane stalling the full 8s backstop (which read as a ~20s cold start).
353
+ const FAST = 2500;
354
+ const att = await send("Target.attachToTarget", { targetId: info.targetId, flatten: true }, undefined, FAST);
355
+ const sid = att.result && att.result.sessionId;
356
+ if (!sid) return; // not ready yet — next poll retries
357
+ const pane = makePane(info);
358
+ pane.sid = sid;
359
+ pane.tabId = info.tabId;
360
+ panes.set(info.targetId, pane);
361
+ sessionHandlers.set(sid, (m) => {
362
+ if (m.method !== "Page.screencastFrame") return;
363
+ pane.lastFrame = Date.now();
364
+ draw(pane, m.params.data); // NB: a repaint is NOT activity — pages paint passively
365
+ send("Page.screencastFrameAck", { sessionId: m.params.sessionId }, sid);
366
+ });
367
+ await send("Page.enable", {}, sid);
368
+ // fire the first-paint still immediately (don't await) so the pane shows ASAP,
369
+ // and kick off the screencast in parallel — neither blocks the other.
370
+ forceCapture(pane); // still: fastest first paint; the 2s heartbeat repaints thereafter
371
+ send("Page.startScreencast",
372
+ { format: "jpeg", quality: 50, maxWidth: 900, maxHeight: 560, everyNthFrame: 1 }, sid);
373
+ }
374
+
375
+ function removePane(targetId) {
376
+ const p = panes.get(targetId);
377
+ if (!p) return;
378
+ if (p.sid) {
379
+ try { send("Page.stopScreencast", {}, p.sid); } catch {}
380
+ try { send("Target.detachFromTarget", { sessionId: p.sid }); } catch {}
381
+ sessionHandlers.delete(p.sid);
382
+ }
383
+ p.el.remove();
384
+ panes.delete(targetId);
385
+ }
386
+
387
+ // Reconcile the live tab set into the fixed-slot wall. Non-reentrant: watch()
388
+ // awaits an attach before it registers its pane, so two overlapping runs could
389
+ // both attach the SAME target — spawning a duplicate pane whose loser orphans.
390
+ // The lock serialises runs; a request mid-flight re-queues.
391
+ let reconcileBusy = false, reconcileQueued = false;
392
+ async function reconcile() {
393
+ if (reconcileBusy) { reconcileQueued = true; return; }
394
+ reconcileBusy = true;
395
+ try {
396
+ const agents = await discover();
397
+ const byId = new Map(agents.map((a) => [a.targetId, a]));
398
+ const N = +gridSel.value || 2;
399
+ const cap = N * N;
400
+ slots = computeSlots(slots, agents, cap, Date.now(), HOT_MS);
401
+
402
+ // Render the sidebar FIRST — it's pure DOM (no CDP), so the tab list always
403
+ // appears even if pane attaches are slow or failing after a restart.
404
+ renderSidebar(slots, agents, byId, cap);
405
+ statTabs.textContent = agents.length;
406
+
407
+ const shownSet = new Set(slots.filter((id) => id != null));
408
+ for (const tid of [...panes.keys()]) if (!shownSet.has(tid)) removePane(tid);
409
+
410
+ // Attach all missing panes IN PARALLEL. Each watch() is ~3 serial CDP
411
+ // round-trips; doing them one-at-a-time made a fresh 3×3 wall take ~30 trips
412
+ // back-to-back. Concurrently, wall-clock ≈ the slowest single tab. The Map
413
+ // key + orphan sweep already prevent duplicate panes, and the reconcile lock
414
+ // means only one batch runs at a time, so parallel attach is safe.
415
+ await Promise.all(slots.map((id) => (id != null && !panes.has(id))
416
+ ? watch(byId.get(id)).catch(() => {}) : null));
417
+
418
+ for (let i = 0; i < cap; i++) {
419
+ const id = slots[i];
420
+ if (id == null) continue;
421
+ const a = byId.get(id);
422
+ const p = panes.get(id);
423
+ if (!p) continue;
424
+ // pin the pane to its slot's grid cell (row-major). Recomputed each tick so a
425
+ // grid-size change (slot index → different cell) repositions correctly.
426
+ p.el.style.gridColumn = (i % N) + 1;
427
+ p.el.style.gridRow = Math.floor(i / N) + 1;
428
+ p.badge.textContent = i + 1;
429
+ p.el.classList.toggle("is-active", (Date.now() - a.lastActivity) < HOT_MS);
430
+ }
431
+ // orphan sweep: drop any pane DOM that isn't the tracked pane for its tab
432
+ for (const el of grid.querySelectorAll(".pane")) {
433
+ const tracked = panes.get(el.dataset.tid);
434
+ if (!tracked || tracked.el !== el) el.remove();
435
+ }
436
+ emptyEl.hidden = panes.size > 0;
437
+ layout();
438
+ } finally {
439
+ reconcileBusy = false;
440
+ if (reconcileQueued) { reconcileQueued = false; scheduleReconcile(); }
441
+ }
442
+ }
443
+
444
+ let reconcileTimer;
445
+ function scheduleReconcile() { clearTimeout(reconcileTimer); reconcileTimer = setTimeout(reconcile, 250); }
446
+ for (const ev of [chrome.tabs.onCreated, chrome.tabs.onRemoved, chrome.tabs.onUpdated,
447
+ chrome.tabs.onMoved, chrome.tabs.onAttached, chrome.tabs.onDetached,
448
+ chrome.tabs.onActivated,
449
+ chrome.tabGroups.onCreated, chrome.tabGroups.onUpdated, chrome.tabGroups.onRemoved]) {
450
+ ev.addListener(scheduleReconcile);
451
+ }
452
+ // 1s poll: re-runs the slot machine so an idling slot crosses the HOT_MS guard and
453
+ // any standing-by tab can enter promptly — and keeps the relative times ticking.
454
+ setInterval(reconcile, 1000);
455
+
456
+ // ── fixed N×N layout ─────────────────────────────────────────────────────────
457
+ // The wall is a uniform N×N grid that always FILLS the stage: every cell is an
458
+ // equal fraction (1fr), so a cell's size depends only on the stage size and N —
459
+ // never on what's playing in it. Each feed is letterboxed inside its own cell by
460
+ // the canvas's object-fit:contain, so panes never jump or re-ratio as frames
461
+ // arrive. CSS reflows the 1fr cells on its own as the stage resizes (window
462
+ // resize, sidebar collapse), so there's no per-frame JS layout to drive.
463
+ function layout() {
464
+ const N = +gridSel.value || 2;
465
+ grid.style.gridTemplateColumns = `repeat(${N}, 1fr)`;
466
+ grid.style.gridTemplateRows = `repeat(${N}, 1fr)`;
467
+ }
468
+ // persist the grid size across reloads / browser restarts
469
+ const GRID_KEY = "hb-monitor-grid";
470
+ const savedGrid = localStorage.getItem(GRID_KEY);
471
+ if (savedGrid === "2" || savedGrid === "3") gridSel.value = savedGrid;
472
+ gridSel.addEventListener("change", () => { // grid size changes the cap → resize slots
473
+ localStorage.setItem(GRID_KEY, gridSel.value);
474
+ layout();
475
+ reconcile();
476
+ });
477
+
478
+ // ── sidebar collapse + hide (persisted) ──────────────────────────────────────
479
+ // Two independent affordances:
480
+ // • collapse — shrink between the full panel (248px) and the icon rail (60px);
481
+ // toggled by the chevron or the logo.
482
+ // • hide — remove the panel entirely so the wall fills the window; only a
483
+ // floating horse remains, which reveals it again. The collapsed/expanded
484
+ // state is kept while hidden, so revealing restores it. ⌘B toggles hide.
485
+ const COLLAPSE_KEY = "hb-monitor-collapsed";
486
+ const HIDDEN_KEY = "hb-monitor-hidden";
487
+ const logoEl = document.querySelector(".logo");
488
+ const hideBtn = document.getElementById("hide");
489
+ const revealBtn = document.getElementById("reveal");
490
+
491
+ function setCollapsed(c) {
492
+ document.body.classList.toggle("collapsed", c);
493
+ localStorage.setItem(COLLAPSE_KEY, c ? "1" : "0");
494
+ // the 1fr cells track the stage width as the sidebar animates — pure CSS, no JS.
495
+ }
496
+ function setHidden(h) {
497
+ document.body.classList.toggle("sb-hidden", h);
498
+ localStorage.setItem(HIDDEN_KEY, h ? "1" : "0");
499
+ }
500
+ const isHidden = () => document.body.classList.contains("sb-hidden");
501
+
502
+ // Collapsed by default (maximise the wall); expanded only if the user explicitly chose it.
503
+ if (localStorage.getItem(COLLAPSE_KEY) !== "0") document.body.classList.add("collapsed");
504
+ if (localStorage.getItem(HIDDEN_KEY) === "1") document.body.classList.add("sb-hidden");
505
+
506
+ collapseBtn.addEventListener("click", () => setCollapsed(!document.body.classList.contains("collapsed")));
507
+ logoEl.addEventListener("click", () => setCollapsed(!document.body.classList.contains("collapsed")));
508
+ hideBtn.addEventListener("click", () => setHidden(true));
509
+ revealBtn.addEventListener("click", () => setHidden(false));
510
+ // "?" → open the welcome page (same one shown on first install)
511
+ document.getElementById("help").addEventListener("click", () => {
512
+ const url = chrome.runtime.getURL("hello.html");
513
+ if (chrome.tabs && chrome.tabs.create) chrome.tabs.create({ url, active: true });
514
+ else window.open(url, "_blank");
515
+ });
516
+ // ⌘B / Ctrl+B fully hides / reveals the panel (VS Code / Chrome side-panel feel)
517
+ window.addEventListener("keydown", (e) => {
518
+ if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && (e.key === "b" || e.key === "B")) {
519
+ e.preventDefault();
520
+ setHidden(!isHidden());
521
+ }
522
+ });
523
+
524
+ // quietly refresh thumbnails for tabs that aren't streaming frames (no blink)
525
+ setInterval(() => {
526
+ const now = Date.now();
527
+ for (const p of panes.values())
528
+ if (now - p.lastFrame > 4000 && now - (p.lastPing || 0) > 5000) { p.lastPing = now; forceCapture(p); }
529
+ }, 2000);
530
+
531
+ (async () => {
532
+ layout();
533
+ let ok = false;
534
+ for (let i = 0; i < 60 && !ok; i++) {
535
+ try { await connect(); ok = true; }
536
+ catch { statTabs.textContent = "…"; await new Promise((r) => setTimeout(r, 1000)); }
537
+ }
538
+ if (!ok) { statTabs.textContent = "—"; return; }
539
+ ws.onclose = () => setTimeout(() => location.reload(), 1500); // browser restart → reconnect
540
+ await reconcile();
541
+ })();