@oddessentials/agent-guild 0.28.0 → 0.28.1

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.
package/README.md CHANGED
@@ -68,6 +68,12 @@ session name in the header switches the focused terminal to another session.
68
68
  On a phone one terminal shows at a time, with a button to switch to the
69
69
  other. This browser reopens the terminals you had open after a reload.
70
70
 
71
+ On phones and tablets, a strip below the terminal provides **← ↑ ↓ → Enter
72
+ Esc**. Tap once to move through a prompt, confirm a choice or send Escape.
73
+ The keys act on the focused terminal and leave the on-screen keyboard as
74
+ it is. They are disabled while the terminal reconnects or after it exits;
75
+ taps made while disconnected are never replayed.
76
+
71
77
  On a touchscreen, **Copy…** opens a still copy of the terminal text. Touch and
72
78
  hold to select, then use **Copy selection** or the browser's Copy menu. **Done**
73
79
  returns to the live terminal; the session keeps running while you copy. The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "0.28.0",
3
+ "version": "0.28.1",
4
4
  "description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/web/app.js CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { SOUNDS, MAX_ALERT_AGE_MS, playOnce, rearmSound, managerLossWatcher, stopWatcher, updateWatcher } from './alerts.js';
5
5
  import { TerminalCopy } from './terminal-copy.js';
6
+ import { TerminalControls, bindTerminalViewport } from './terminal-controls.js';
6
7
  import { topbarInline, dockMode, clampDockWidth, stageBesideDock, splitMode, clampRatio, bindSplitter, DOCK_MIN, SPLIT_RATIO_MIN } from './layout.js';
7
8
  import { highlightParts, rankRepos, recentFirst, remember, repoForOrigin, repoKey } from './repo-search.js';
8
9
  import { createActivityFavicon, isSessionWorking } from './activity-favicon.js';
@@ -124,6 +125,7 @@ function setConnection(kind, label) {
124
125
  el.title = label;
125
126
  // The manager can only be stopped, restarted or upgraded while the page can reach it.
126
127
  state.connected = kind === 'ok';
128
+ terminalControls.refresh();
127
129
  state.remoteAccessUI?.connectionChanged();
128
130
  $('manager').hidden = !state.connected;
129
131
  if (!state.connected) closeMenu($('manager-menu'));
@@ -4426,11 +4428,18 @@ class TerminalView {
4426
4428
 
4427
4429
  connect() {
4428
4430
  if (this.disposed || state.remoteRevoked) return;
4431
+ this.inputReady = false;
4432
+ this.inputSnapshot = null;
4433
+ terminalControls.refresh();
4429
4434
  const ws = new WebSocket(wsUrl(`/sessions/${this.id}/terminal`));
4430
4435
  this.ws = ws;
4431
4436
  ws.onopen = () => { this.retry = 0; this.sent = { cols: 0, rows: 0 }; this.sendSize(); };
4432
- ws.onmessage = (event) => this.onMessage(JSON.parse(event.data));
4437
+ ws.onmessage = (event) => { if (!this.disposed && this.ws === ws) this.onMessage(JSON.parse(event.data)); };
4433
4438
  ws.onclose = (event) => {
4439
+ if (this.ws !== ws) return;
4440
+ this.inputReady = false;
4441
+ this.inputSnapshot = null;
4442
+ terminalControls.refresh();
4434
4443
  if (this.disposed || event.code === 4403 || event.code === 4404 || event.code === 4410) return;
4435
4444
  const delay = Math.min(5000, 300 * 2 ** this.retry++);
4436
4445
  setTimeout(() => this.connect(), delay);
@@ -4440,14 +4449,31 @@ class TerminalView {
4440
4449
  onMessage(msg) {
4441
4450
  switch (msg.type) {
4442
4451
  case 'snapshot':
4452
+ this.inputReady = false;
4453
+ this.inputSnapshot = msg;
4454
+ this.inputRun = msg.session.startedAt;
4455
+ terminalControls.refresh();
4443
4456
  this.term.reset();
4444
4457
  this.term.resize(msg.cols, msg.rows);
4445
- this.term.write(msg.data, () => { this.sent = { cols: 0, rows: 0 }; this.refit(); });
4458
+ {
4459
+ const ws = this.ws;
4460
+ this.term.write(msg.data, () => {
4461
+ if (this.disposed || this.ws !== ws || ws.readyState !== WebSocket.OPEN || this.inputSnapshot !== msg) return;
4462
+ this.inputReady = msg.session.status === 'running';
4463
+ this.inputSnapshot = null;
4464
+ this.sent = { cols: 0, rows: 0 };
4465
+ this.refit();
4466
+ terminalControls.refresh();
4467
+ });
4468
+ }
4446
4469
  break;
4447
4470
  case 'data':
4448
4471
  this.term.write(msg.data);
4449
4472
  break;
4450
4473
  case 'exit':
4474
+ this.inputReady = false;
4475
+ this.inputSnapshot = null;
4476
+ terminalControls.refresh();
4451
4477
  if (dictation?.id === this.id) stopDictation();
4452
4478
  this.term.write(`\r\n\x1b[2m${exitLine(state.sessions.get(this.id), msg)}\x1b[0m\r\n`);
4453
4479
  break;
@@ -4532,6 +4558,9 @@ class TerminalView {
4532
4558
 
4533
4559
  dispose() {
4534
4560
  this.disposed = true;
4561
+ this.inputReady = false;
4562
+ this.inputSnapshot = null;
4563
+ terminalControls.refresh();
4535
4564
  this.resizeObserver.disconnect();
4536
4565
  this.ws?.close();
4537
4566
  this.term.dispose();
@@ -4541,6 +4570,20 @@ class TerminalView {
4541
4570
 
4542
4571
  // ---- terminal panel -------------------------------------------------------
4543
4572
 
4573
+ const terminalControls = new TerminalControls({
4574
+ element: $('terminal-controls'), panel: $('terminal-panel'),
4575
+ getCurrent: () => {
4576
+ const session = state.sessions.get(state.activeId);
4577
+ const view = state.views.get(state.activeId);
4578
+ if (!session || session.status !== 'running' || !view?.inputReady || view.disposed
4579
+ || session.startedAt !== view.inputRun || view.ws?.readyState !== WebSocket.OPEN
4580
+ || !state.connected || state.pageAway || state.remoteRevoked || state.stopping
4581
+ || $('app').hidden || $('terminal-panel').hidden || !view.el.isConnected) return null;
4582
+ return { term: view.term, socket: view.ws, run: session.startedAt, name: session.name };
4583
+ },
4584
+ });
4585
+ bindTerminalViewport($('terminal-panel'), $('terminal-controls'));
4586
+
4544
4587
  const terminalCopy = new TerminalCopy({
4545
4588
  opener: $('panel-copy'), dialog: $('terminal-copy'),
4546
4589
  getCurrent: () => {
@@ -4578,7 +4621,7 @@ function openPanel(id, { beside = false, restoring = false } = {}) {
4578
4621
  function focusPane(index, { focusTerminal = true } = {}) {
4579
4622
  const id = state.panes[index];
4580
4623
  if (!id) return;
4581
- if (state.activeId !== id) terminalCopy.close();
4624
+ if (state.activeId !== id) { terminalCopy.close(); terminalControls.cancel(); }
4582
4625
  if (dictation && dictation.id !== id) stopDictation();
4583
4626
  state.focusedPane = index;
4584
4627
  state.activeId = id;
@@ -4599,6 +4642,7 @@ function closePane(index, { focusTerminal = true } = {}) {
4599
4642
  }
4600
4643
 
4601
4644
  function closePanel() {
4645
+ terminalControls.cancel();
4602
4646
  terminalCopy.close();
4603
4647
  stopDictation();
4604
4648
  for (const id of state.panes) state.views.get(id)?.unmount();
@@ -4613,7 +4657,8 @@ function closePanel() {
4613
4657
 
4614
4658
  function stageSplit() {
4615
4659
  const box = $('terminal-panel').getBoundingClientRect();
4616
- return splitMode(box.width, box.height);
4660
+ const controls = $('terminal-controls');
4661
+ return splitMode(box.width, box.height - (controls.hidden ? 0 : controls.offsetHeight));
4617
4662
  }
4618
4663
 
4619
4664
  function layoutPanes() {
@@ -4709,6 +4754,7 @@ function restorePanes() {
4709
4754
  }
4710
4755
 
4711
4756
  function updatePanel() {
4757
+ terminalControls.refresh();
4712
4758
  renderVoice();
4713
4759
  const s = state.sessions.get(state.activeId);
4714
4760
  if (!s) return;
@@ -5222,7 +5268,12 @@ bindSplitter($('pane-splitter'), {
5222
5268
  home: () => setSplitRatio(SPLIT_RATIO_MIN),
5223
5269
  endKey: () => setSplitRatio(1 - SPLIT_RATIO_MIN),
5224
5270
  });
5225
- new ResizeObserver(layoutPanes).observe($('terminal-panel'));
5271
+ let terminalLayoutFrame;
5272
+ const terminalLayoutObserver = new ResizeObserver(() => {
5273
+ cancelAnimationFrame(terminalLayoutFrame);
5274
+ terminalLayoutFrame = requestAnimationFrame(layoutPanes);
5275
+ });
5276
+ for (const node of [$('terminal-panel'), $('terminal-controls')]) terminalLayoutObserver.observe(node);
5226
5277
  $('panel-voice').addEventListener('click', () => {
5227
5278
  if (dictation) {
5228
5279
  const { id } = dictation;
@@ -5570,8 +5621,13 @@ setInterval(() => { if (dockShows('github') && state.github) renderGitHub(); },
5570
5621
  // The terminal panel and the dock sit below the top bar; publish where it ends so they never cover its controls.
5571
5622
  const topbar = document.querySelector('.topbar');
5572
5623
  const publishTopbarHeight = () => document.documentElement.style.setProperty('--topbar-h', `${Math.max(0, Math.round(topbar.getBoundingClientRect().bottom))}px`);
5573
- new ResizeObserver(publishTopbarHeight).observe(topbar);
5574
- addEventListener('scroll', publishTopbarHeight, { passive: true });
5624
+ let topbarFrame;
5625
+ const scheduleTopbarHeight = () => {
5626
+ cancelAnimationFrame(topbarFrame);
5627
+ topbarFrame = requestAnimationFrame(publishTopbarHeight);
5628
+ };
5629
+ new ResizeObserver(scheduleTopbarHeight).observe(topbar);
5630
+ addEventListener('scroll', scheduleTopbarHeight, { passive: true });
5575
5631
  publishTopbarHeight();
5576
5632
  applyDockLayout();
5577
5633
 
package/web/index.html CHANGED
@@ -169,6 +169,14 @@
169
169
  <div class="terminal-host"></div>
170
170
  </section>
171
171
  </div>
172
+ <div id="terminal-controls" class="terminal-controls" role="group" aria-label="Terminal keys" hidden>
173
+ <button class="btn terminal-key" type="button" data-key="ArrowLeft" aria-label="Left arrow" title="Left arrow" disabled>←</button>
174
+ <button class="btn terminal-key" type="button" data-key="ArrowUp" aria-label="Up arrow" title="Up arrow" disabled>↑</button>
175
+ <button class="btn terminal-key" type="button" data-key="ArrowDown" aria-label="Down arrow" title="Down arrow" disabled>↓</button>
176
+ <button class="btn terminal-key" type="button" data-key="ArrowRight" aria-label="Right arrow" title="Right arrow" disabled>→</button>
177
+ <button class="btn terminal-key" type="button" data-key="Enter" title="Send Enter to the terminal" disabled>Enter</button>
178
+ <button class="btn terminal-key" type="button" data-key="Escape" aria-label="Escape" title="Send Escape to the terminal" disabled>Esc</button>
179
+ </div>
172
180
  <div id="voice-preview" class="voice-preview" hidden aria-live="polite"></div>
173
181
  </div>
174
182
 
package/web/styles.css CHANGED
@@ -443,7 +443,7 @@ body:has(#app[hidden]):has(#auth[hidden]):has(#stopped[hidden])::after {
443
443
 
444
444
  .terminal-panel {
445
445
  /* Below the top bar, whose bottom edge the page publishes as --topbar-h. */
446
- position: fixed; inset: calc(var(--topbar-h, 52px) + 12px) calc(16px + var(--dock-stage-space, 0px)) 16px 16px; z-index: 10; display: flex; flex-direction: column;
446
+ position: fixed; inset: max(calc(var(--topbar-h, 52px) + 12px), var(--terminal-viewport-top, 0px)) calc(16px + var(--dock-stage-space, 0px)) calc(16px + var(--terminal-viewport-bottom, 0px)) 16px; z-index: 10; display: flex; flex-direction: column;
447
447
  background: var(--term-bg); border: 1px solid var(--border); border-radius: var(--radius);
448
448
  box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35); overflow: hidden;
449
449
  }
@@ -518,6 +518,30 @@ body:has(#app[hidden]):has(#auth[hidden]):has(#stopped[hidden])::after {
518
518
  }
519
519
  .terminal-host { flex: 1; min-height: 0; padding: 8px 4px 4px 10px; }
520
520
  .terminal-host > div { height: 100%; }
521
+ .terminal-controls {
522
+ flex: none; display: flex; justify-content: center; gap: 4px;
523
+ padding: 6px max(8px, env(safe-area-inset-right)) max(6px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left));
524
+ border-top: 1px solid var(--border); background: var(--surface);
525
+ touch-action: manipulation; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
526
+ }
527
+ .terminal-controls .terminal-key {
528
+ flex: 1 1 0; min-width: 44px; max-width: 64px; min-height: 48px; padding: 4px;
529
+ font: 600 0.85rem var(--font-mono); touch-action: manipulation;
530
+ }
531
+ .terminal-key[data-key^="Arrow"] { font-size: 1.3rem; }
532
+ .terminal-key[data-key="Enter"] { flex-grow: 1.25; max-width: 80px; }
533
+ .terminal-key.pressed:not(:disabled) { background: var(--accent-fill); border-color: var(--accent-fill); color: var(--accent-text); transform: translateY(1px); }
534
+ .terminal-key:disabled { cursor: default; }
535
+ .terminal-panel:has(.terminal-controls:not([hidden])) .voice-preview { bottom: calc(72px + env(safe-area-inset-bottom)); }
536
+ /* A docked keyboard in landscape leaves little height. Keep the session and actions
537
+ available in one header row; Hide still returns to the normal page. */
538
+ .terminal-panel[data-compact] { top: var(--terminal-viewport-top, 0px); }
539
+ .terminal-panel[data-compact] .panel-bar { flex-wrap: nowrap; gap: 6px; padding: 6px 8px; }
540
+ .terminal-panel[data-compact] .panel-title { flex: 1 1 0; }
541
+ .terminal-panel[data-compact] :is(.panel-title .sub, .panel-title .agents, .panel-title .provider-icon) { display: none; }
542
+ .terminal-panel[data-compact] .panel-actions { flex-wrap: nowrap; gap: 4px; max-width: 65%; overflow-x: auto; }
543
+ .terminal-panel[data-compact] .panel-actions .btn { flex: none; }
544
+ .terminal-panel[data-compact] #panel-close { position: sticky; right: 0; z-index: 1; box-shadow: -4px 0 0 var(--surface); }
521
545
  .terminal-copy {
522
546
  width: min(880px, calc(100vw - 24px)); height: min(720px, calc(100dvh - 24px));
523
547
  max-width: none; max-height: none; padding: 0; overflow: hidden;
@@ -545,7 +569,7 @@ body:has(#app[hidden]):has(#auth[hidden]):has(#stopped[hidden])::after {
545
569
  }
546
570
  @media (pointer: coarse) and (max-width: 900px) {
547
571
  /* Tablets in portrait are wider than the phone layout but still want the whole width. */
548
- .terminal-panel { inset: var(--topbar-h, 56px) 0 0; border-radius: 0; }
572
+ .terminal-panel { inset: max(var(--topbar-h, 56px), var(--terminal-viewport-top, 0px)) 0 var(--terminal-viewport-bottom, 0px); border-radius: 0; }
549
573
  }
550
574
 
551
575
  .models {
@@ -558,7 +582,8 @@ body:has(#app[hidden]):has(#auth[hidden]):has(#stopped[hidden])::after {
558
582
  .models-bar { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--border); }
559
583
  .models-heading { flex: 1; min-width: 0; }
560
584
  .models-heading .sub { margin: 2px 0 0; color: var(--muted); font-size: 0.78rem; }
561
- .models-body { flex: 1; min-height: 0; overflow: auto; padding: 0 14px 14px; }
585
+ /* Content supplies the basis for auto-height dialogs; a zero basis collapses in Safari. */
586
+ .models-body { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 0 14px 14px; }
562
587
  .remote-access { width: min(560px, calc(100vw - 24px)); max-height: calc(100dvh - 24px); }
563
588
  .remote-body { padding: 4px 20px 20px; font-size: 0.9rem; }
564
589
  .remote-body > p { color: var(--muted); line-height: 1.6; }
@@ -968,7 +993,7 @@ a.btn.github-out::after {
968
993
  @media (max-width: 640px) {
969
994
  .topbar { padding: 10px 16px; }
970
995
  .connection .label { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
971
- .terminal-panel { inset: var(--topbar-h, 56px) 0 0; border-radius: 0; }
996
+ .terminal-panel { inset: max(var(--topbar-h, 56px), var(--terminal-viewport-top, 0px)) 0 var(--terminal-viewport-bottom, 0px); border-radius: 0; }
972
997
  .session-grid { grid-template-columns: 1fr; }
973
998
  .cwd { flex-basis: 100%; }
974
999
  .launch-tools { flex-basis: 100%; justify-content: stretch; }
@@ -0,0 +1,130 @@
1
+ const ARROWS = { ArrowUp: 'A', ArrowDown: 'B', ArrowRight: 'C', ArrowLeft: 'D' };
2
+
3
+ /** Use the same cursor mode as a physical keyboard, including inside full-screen tools. */
4
+ export function terminalKey(key, modes) {
5
+ if (Object.hasOwn(ARROWS, key)) return `\x1b${modes.applicationCursorKeysMode ? 'O' : '['}${ARROWS[key]}`;
6
+ if (key === 'Enter') return '\r';
7
+ if (key === 'Escape') return '\x1b';
8
+ return null;
9
+ }
10
+
11
+ const sameTarget = (a, b) => Boolean(a && b && a.term === b.term && a.socket === b.socket && a.run === b.run);
12
+
13
+ /** Six ordinary buttons. A gesture belongs to the session and connection where it began. */
14
+ export class TerminalControls {
15
+ constructor({ element, panel, getCurrent }) {
16
+ this.element = element;
17
+ this.getCurrent = getCurrent;
18
+ this.buttons = [...element.querySelectorAll('button')];
19
+ const touch = matchMedia('(any-pointer: coarse)');
20
+ const availability = () => {
21
+ element.hidden = !touch.matches && !navigator.maxTouchPoints;
22
+ this.refresh();
23
+ };
24
+ touch.addEventListener('change', availability);
25
+ for (const button of this.buttons) {
26
+ button.addEventListener('pointerdown', (event) => {
27
+ this.cancel();
28
+ if (!event.isPrimary || event.button !== 0 || button.disabled) return;
29
+ // Retain xterm's focus if typing, and leave the keyboard closed if it is closed.
30
+ event.preventDefault();
31
+ const target = this.current();
32
+ if (!target) return;
33
+ this.press = { button, target, pointer: event.pointerId };
34
+ button.setPointerCapture(event.pointerId);
35
+ button.classList.add('pressed');
36
+ });
37
+ button.addEventListener('pointermove', (event) => {
38
+ if (this.press?.pointer === event.pointerId && document.elementFromPoint(event.clientX, event.clientY) !== button) this.cancel();
39
+ });
40
+ button.addEventListener('pointerup', (event) => {
41
+ const press = this.press;
42
+ if (press?.pointer !== event.pointerId) return;
43
+ this.cancel();
44
+ if (document.elementFromPoint(event.clientX, event.clientY) === button) this.activate(button, press.target);
45
+ });
46
+ for (const type of ['pointercancel', 'lostpointercapture', 'blur']) button.addEventListener(type, () => this.cancel());
47
+ button.addEventListener('keydown', (event) => {
48
+ if (event.key !== 'Enter' && event.key !== ' ') return;
49
+ event.preventDefault();
50
+ if (event.repeat) return;
51
+ this.cancel();
52
+ this.press = { button, target: this.current(), key: event.key };
53
+ button.classList.add('pressed');
54
+ });
55
+ button.addEventListener('keyup', (event) => {
56
+ const press = this.press;
57
+ if (!press || press.key !== event.key || press.button !== button) return;
58
+ event.preventDefault();
59
+ this.cancel();
60
+ this.activate(button, press.target);
61
+ });
62
+ // Pointer and keyboard releases were handled above. Keep assistive-technology
63
+ // activation working without sending a second key for the following click.
64
+ button.addEventListener('click', (event) => {
65
+ if (event.detail === 0 && !event.pointerType) this.activate(button, this.current());
66
+ });
67
+ }
68
+ addEventListener('blur', () => this.cancel());
69
+ addEventListener('pagehide', () => this.cancel());
70
+ document.addEventListener('visibilitychange', () => { this.cancel(); this.refresh(); });
71
+ // Modal Copy (and other dialogs) must cancel a press even if it closes before release.
72
+ new MutationObserver(() => { this.cancel(); this.refresh(); }).observe(document.body, {
73
+ subtree: true, attributes: true, attributeFilter: ['open'],
74
+ });
75
+ new MutationObserver(() => this.refresh()).observe(panel, { attributes: true, attributeFilter: ['hidden'] });
76
+ availability();
77
+ }
78
+
79
+ current() {
80
+ if (this.element.hidden || document.visibilityState !== 'visible' || document.querySelector('dialog[open]')) return null;
81
+ return this.getCurrent();
82
+ }
83
+
84
+ refresh() {
85
+ const target = this.current();
86
+ if (!sameTarget(this.press?.target, target)) this.cancel();
87
+ for (const button of this.buttons) button.disabled = !target;
88
+ this.element.setAttribute('aria-label', target ? `Terminal keys for ${target.name}` : 'Terminal keys unavailable');
89
+ }
90
+
91
+ cancel() {
92
+ this.press?.button.classList.remove('pressed');
93
+ this.press = null;
94
+ }
95
+
96
+ activate(button, target) {
97
+ if (!sameTarget(target, this.current())) return;
98
+ const data = terminalKey(button.dataset.key, target.term.modes);
99
+ if (data !== null) target.term.input(data);
100
+ }
101
+ }
102
+
103
+ /** Fit the touch panel to a docked keyboard without changing global page layout. */
104
+ export function bindTerminalViewport(panel, controls) {
105
+ const viewport = window.visualViewport;
106
+ let frame;
107
+ const update = () => {
108
+ if (panel.hidden || controls.hidden) {
109
+ panel.style.removeProperty('--terminal-viewport-top');
110
+ panel.style.removeProperty('--terminal-viewport-bottom');
111
+ delete panel.dataset.compact;
112
+ return;
113
+ }
114
+ // Magnification belongs to the browser; it must not resize the shared PTY.
115
+ if (viewport && Math.abs(viewport.scale - 1) > 0.01) return;
116
+ const top = viewport?.offsetTop || 0;
117
+ const height = viewport?.height ?? innerHeight;
118
+ panel.style.setProperty('--terminal-viewport-top', `${top}px`);
119
+ panel.style.setProperty('--terminal-viewport-bottom', `${Math.max(0, innerHeight - top - height)}px`);
120
+ panel.toggleAttribute('data-compact', height < 420);
121
+ };
122
+ const schedule = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(update); };
123
+ viewport?.addEventListener('resize', schedule);
124
+ viewport?.addEventListener('scroll', schedule);
125
+ addEventListener('resize', schedule);
126
+ addEventListener('pageshow', schedule);
127
+ const observer = new MutationObserver(schedule);
128
+ for (const element of [panel, controls]) observer.observe(element, { attributes: true, attributeFilter: ['hidden'] });
129
+ schedule();
130
+ }