@q-agent/agent 0.1.1 → 0.1.7

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/dist/src/ui.js CHANGED
@@ -35,22 +35,44 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.startUi = startUi;
37
37
  /**
38
- * Local web UI for the agent, served on 127.0.0.1 and opened in the browser.
38
+ * Local web UI for the agent, served on 127.0.0.1 (opened in the browser by the
39
+ * `ui` command, or shown in the Electron desktop window). Pairs by entering a
40
+ * server URL + 6-digit code, then shows the live agent status + log.
39
41
  *
40
- * Lets the user connect by pasting a pair code + server URL (no CLI), then shows
41
- * live pulling/execution progress streamed from the job loop over SSE. Styled to
42
- * match the Q-Agent app (dark, glass, violet). No framework / no extra deps —
43
- * Node's built-in http plus the in-process event bus (bus.ts).
42
+ * The page implements the client's "Local Agent" design (dark, glass,
43
+ * purple→indigo, custom frameless titlebar, segmented 6-digit code, compact
44
+ * connected view). No framework / no extra deps — Node's built-in http plus the
45
+ * in-process event bus (bus.ts).
44
46
  */
45
47
  const node_child_process_1 = require("node:child_process");
46
48
  const http = __importStar(require("node:http"));
47
49
  const os = __importStar(require("node:os"));
50
+ const path = __importStar(require("node:path"));
48
51
  const api_1 = require("./api");
49
52
  const bus_1 = require("./bus");
50
53
  const config_1 = require("./config");
54
+ const paths_1 = require("./paths");
51
55
  const runner_1 = require("./runner");
52
56
  let signal = { aborted: false };
53
57
  let running = false;
58
+ /** Playwright's version, for the environment chips (best-effort). */
59
+ function playwrightVersion() {
60
+ try {
61
+ return require(path.join((0, paths_1.agentNodeModules)(), "playwright", "package.json")).version;
62
+ }
63
+ catch {
64
+ return "";
65
+ }
66
+ }
67
+ /** This agent build's version (from its own package.json), shown bottom-right. */
68
+ function appVersion() {
69
+ try {
70
+ return require(path.join(__dirname, "..", "..", "package.json")).version || "";
71
+ }
72
+ catch {
73
+ return "";
74
+ }
75
+ }
54
76
  /** Start the claim→run loop for `cfg` (no-op if already running). */
55
77
  function startLoop(cfg) {
56
78
  if (running)
@@ -76,8 +98,14 @@ function state() {
76
98
  paired: Boolean(cfg),
77
99
  running,
78
100
  deviceId: cfg?.deviceId ?? null,
79
- deviceName: cfg?.deviceName ?? null,
80
- serverUrl: cfg?.serverUrl ?? "",
101
+ deviceName: cfg?.deviceName ?? os.hostname(),
102
+ // Pre-fill the server from the paired config, else the value baked into this
103
+ // build — so a downloaded desktop app needs only the 6-digit code.
104
+ serverUrl: cfg?.serverUrl ?? (0, config_1.defaultServerUrl)(),
105
+ machine: os.hostname(),
106
+ nodeVersion: process.versions.node,
107
+ playwrightVersion: playwrightVersion(),
108
+ appVersion: appVersion(),
81
109
  events: (0, bus_1.recentEvents)(),
82
110
  };
83
111
  }
@@ -128,7 +156,7 @@ async function handle(req, res) {
128
156
  const { deviceToken, deviceId } = await (0, api_1.redeemDevice)(serverUrl, String(code).trim(), name);
129
157
  const cfg = { serverUrl, deviceToken, deviceId, deviceName: name };
130
158
  (0, config_1.saveConfig)(cfg);
131
- (0, bus_1.emit)("log", { message: `Paired as device #${deviceId} (${name})` });
159
+ (0, bus_1.emit)("log", { message: `paired as device #${deviceId} (${name})` });
132
160
  startLoop(cfg);
133
161
  sendJson(res, 200, { ok: true, deviceId });
134
162
  }
@@ -138,6 +166,12 @@ async function handle(req, res) {
138
166
  return;
139
167
  }
140
168
  if (req.method === "POST" && url === "/api/disconnect") {
169
+ // Tell the server first (token still valid) so the device drops off the
170
+ // owner's SPA list immediately, then wipe the local token + stop the loop.
171
+ const cfg = (0, config_1.loadConfig)();
172
+ if (cfg) {
173
+ await (0, api_1.disconnectDevice)(cfg).catch((err) => (0, bus_1.emit)("log", { message: `server disconnect failed: ${err.message}` }));
174
+ }
141
175
  stopLoop();
142
176
  (0, config_1.clearConfig)();
143
177
  (0, bus_1.emit)("agent-status", { running: false });
@@ -165,7 +199,9 @@ async function handle(req, res) {
165
199
  res.writeHead(404, { "Content-Type": "text/plain" });
166
200
  res.end("Not found");
167
201
  }
168
- /** Start the UI server, begin the loop if already paired, and open the browser. */
202
+ /** Start the UI server, begin the loop if already paired, and open the browser.
203
+ * `onListening` receives the actual URL (the port may bump if 7420 is taken) —
204
+ * the Electron shell uses it to load the window. */
169
205
  function startUi(opts = {}) {
170
206
  let port = opts.port ?? 7420;
171
207
  const server = http.createServer((req, res) => {
@@ -190,199 +226,402 @@ function startUi(opts = {}) {
190
226
  const cfg = (0, config_1.loadConfig)();
191
227
  if (cfg)
192
228
  startLoop(cfg);
229
+ opts.onListening?.(addr);
193
230
  if (opts.open !== false)
194
231
  openBrowser(addr);
195
232
  });
196
233
  }
197
234
  // ---------------------------------------------------------------- the page
235
+ // Client's "Local Agent" design, ported to vanilla HTML/CSS/JS and wired to the
236
+ // /api/* endpoints. The client script avoids template literals / ${} so this
237
+ // server-side template literal needs no escaping.
198
238
  const PAGE = `<!doctype html>
199
239
  <html lang="en">
200
240
  <head>
201
241
  <meta charset="utf-8" />
202
242
  <meta name="viewport" content="width=device-width, initial-scale=1" />
203
243
  <title>Q-Agent · Local Agent</title>
244
+ <link rel="preconnect" href="https://api.fontshare.com" />
245
+ <link href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,600,700,900&display=swap" rel="stylesheet" />
246
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
204
247
  <style>
205
- :root {
206
- --bg: #0f0f16; --panel: rgba(24,24,32,.72); --border: rgba(255,255,255,.10);
207
- --ink: #ececf1; --soft: #c3c3d0; --dim: #8b8b9e; --violet: #8b5cf6; --violet-2: #a78bfa;
208
- --green: #34d399; --red: #f43f5e; --amber: #f59e0b;
209
- }
210
- * { box-sizing: border-box; }
211
- body {
212
- margin: 0; font: 14px/1.5 -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--ink);
213
- background: radial-gradient(1000px 600px at 80% -10%, rgba(139,92,246,.18), transparent 60%), var(--bg);
214
- min-height: 100vh;
215
- }
216
- .wrap { max-width: 760px; margin: 0 auto; padding: 40px 20px 60px; }
217
- .brand { display: flex; align-items: center; gap: 11px; margin-bottom: 22px; }
218
- .logo { width: 38px; height: 38px; border-radius: 11px; background: linear-gradient(135deg,#8b5cf6,#f59e0b);
219
- display: flex; align-items: center; justify-content: center; font-size: 20px; }
220
- .brand h1 { margin: 0; font-size: 19px; font-weight: 800; letter-spacing: -.02em; }
221
- .brand .sub { font-size: 12px; color: var(--dim); }
222
- .card { background: var(--panel); border: 1px solid var(--border); border-radius: 16px; padding: 18px;
223
- margin-bottom: 16px; backdrop-filter: blur(8px); box-shadow: 0 30px 70px -30px #000; }
224
- .card h2 { margin: 0 0 14px; font-size: 11px; letter-spacing: .1em; color: var(--dim); font-weight: 800; }
225
- label { display: block; font-size: 12px; color: var(--soft); margin: 12px 0 5px; }
226
- input, textarea { width: 100%; background: rgba(0,0,0,.30); border: 1px solid var(--border); border-radius: 10px;
227
- color: var(--ink); padding: 10px 12px; font: inherit; }
228
- input:focus, textarea:focus { outline: none; border-color: var(--violet); }
229
- textarea { resize: vertical; min-height: 60px; font-family: ui-monospace, monospace; font-size: 12px; }
230
- button { cursor: pointer; border: none; border-radius: 10px; font: inherit; font-weight: 700; padding: 10px 16px; }
231
- .primary { background: linear-gradient(135deg,#8b5cf6,#7c3aed); color: #fff; }
232
- .primary:disabled { opacity: .6; cursor: default; }
233
- .ghost { background: rgba(255,255,255,.06); color: var(--soft); }
234
- .row { display: flex; align-items: center; gap: 10px; }
235
- .pill { font-size: 11px; font-weight: 700; padding: 3px 10px; border-radius: 999px; display: inline-flex; align-items: center; gap: 6px; }
236
- .dot { width: 7px; height: 7px; border-radius: 50%; }
237
- .err { color: var(--red); font-size: 12px; margin-top: 10px; min-height: 16px; }
238
- .mut { color: var(--dim); font-size: 12px; }
239
- .bar { height: 8px; border-radius: 999px; background: rgba(255,255,255,.08); overflow: hidden; margin: 12px 0 6px; }
240
- .bar > i { display: block; height: 100%; background: linear-gradient(90deg,#8b5cf6,#a78bfa); width: 0; transition: width .3s; }
241
- .counts { display: flex; gap: 16px; font-size: 12px; }
242
- .banner { background: rgba(245,158,11,.12); border: 1px solid rgba(245,158,11,.35); color: #f6c177;
243
- border-radius: 10px; padding: 10px 12px; font-size: 12.5px; margin-bottom: 12px; display: none; }
244
- .feed { display: flex; flex-direction: column; gap: 8px; max-height: 360px; overflow: auto; }
245
- .ev { display: flex; gap: 10px; align-items: baseline; font-size: 12.5px; padding: 7px 10px; border-radius: 9px;
246
- background: rgba(255,255,255,.03); }
247
- .ev .t { color: var(--dim); font-family: ui-monospace, monospace; font-size: 10.5px; flex-shrink: 0; }
248
- .ev .m { color: var(--soft); }
249
- .ev.pass { border-left: 2px solid var(--green); } .ev.fail { border-left: 2px solid var(--red); }
250
- .ev.err { border-left: 2px solid var(--red); } .ev.err .m { color: #fca5a5; }
251
- .badge { font-size: 10px; font-weight: 800; padding: 1px 7px; border-radius: 999px; }
252
- .badge.pass { background: rgba(52,211,153,.18); color: var(--green); }
253
- .badge.fail { background: rgba(244,63,94,.18); color: var(--red); }
254
- .hidden { display: none; }
255
- code { background: rgba(0,0,0,.3); padding: 1px 5px; border-radius: 5px; font-size: 11.5px; color: var(--violet-2); }
248
+ *{box-sizing:border-box}
249
+ html,body{margin:0;height:100%}
250
+ body{background:#0a0a0f;color:#ECECF1;font-family:'Satoshi',system-ui,sans-serif;-webkit-font-smoothing:antialiased;overflow:hidden}
251
+ ::selection{background:rgba(139,92,246,.4);color:#fff}
252
+ input::placeholder{color:#5c5c6e}
253
+ ::-webkit-scrollbar{width:9px}::-webkit-scrollbar-thumb{background:rgba(255,255,255,.1);border-radius:8px}
254
+ @keyframes fadeUp{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
255
+ @keyframes glowPulse{0%,100%{opacity:.5}50%{opacity:.85}}
256
+ @keyframes spin{to{transform:rotate(360deg)}}
257
+ @keyframes livePulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.35;transform:scale(.7)}}
258
+ @keyframes logoHalo{0%,100%{opacity:.5;transform:scale(.92)}50%{opacity:1;transform:scale(1.12)}}
259
+ @keyframes logIn{from{opacity:0;transform:translateX(-8px)}to{opacity:1;transform:none}}
260
+ @keyframes barPulse{0%,100%{transform:scaleY(.35)}50%{transform:scaleY(1)}}
261
+ @keyframes caret{0%,49%{opacity:1}50%,100%{opacity:0}}
262
+ .ctl{width:44px;height:32px;display:flex;align-items:center;justify-content:center;background:none;border:none;cursor:pointer;color:#8b8b9e;border-radius:7px}
263
+ .ctl:hover{background:rgba(255,255,255,.07);color:#ececf1}
264
+ .ctl.close:hover{background:#e5484d;color:#fff}
265
+ input:focus{outline:none}
266
+ .cell{position:relative;display:flex;align-items:center;justify-content:center;height:54px;border-radius:12px;background:#16161f;font-family:'JetBrains Mono',monospace;font-size:24px;font-weight:600;color:#ECECF1;border:1.5px solid rgba(255,255,255,.09);transition:border-color .16s,box-shadow .16s}
256
267
  </style>
257
268
  </head>
258
269
  <body>
259
- <div class="wrap">
260
- <div class="brand">
261
- <div class="logo">⭐</div>
262
- <div>
263
- <h1>Q-Agent · Local Agent</h1>
264
- <div class="sub">Runs Playwright on this machine — your session stays local.</div>
270
+ <div style="position:fixed;inset:0;display:flex;flex-direction:column;background:#0a0a0f;overflow:hidden;border:1px solid rgba(255,255,255,.08)">
271
+ <div style="position:absolute;top:-16%;left:-10%;width:520px;height:520px;border-radius:50%;background:radial-gradient(circle,rgba(139,92,246,.26),transparent 62%);filter:blur(30px);z-index:0;pointer-events:none;animation:glowPulse 9s ease-in-out infinite"></div>
272
+ <div style="position:absolute;bottom:-22%;right:-12%;width:600px;height:600px;border-radius:50%;background:radial-gradient(circle,rgba(99,102,241,.22),transparent 62%);filter:blur(30px);z-index:0;pointer-events:none;animation:glowPulse 11s ease-in-out infinite 1s"></div>
273
+
274
+ <!-- titlebar (frameless electron chrome; draggable) -->
275
+ <div style="position:relative;z-index:5;flex-shrink:0;height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 4px 0 13px;background:rgba(14,14,20,.72);backdrop-filter:blur(24px);border-bottom:1px solid rgba(255,255,255,.06);-webkit-app-region:drag">
276
+ <div style="display:flex;align-items:center;gap:9px">
277
+ <div style="width:18px;height:18px;border-radius:6px;background:linear-gradient(135deg,#8b5cf6,#6366f1);display:flex;align-items:center;justify-content:center;box-shadow:0 3px 9px -3px rgba(139,92,246,.8)"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.3L19 10l-5.1 1.7L12 17l-1.9-5.3L5 10l5.1-1.7z"/></svg></div>
278
+ <span style="font-size:11.5px;font-weight:600;color:#a0a0b2">Q‑Agent · Local Agent</span>
279
+ </div>
280
+ <div id="winctl" style="display:none;align-items:center;-webkit-app-region:no-drag">
281
+ <button class="ctl" onclick="qa.win('minimize')"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14"/></svg></button>
282
+ <button class="ctl" onclick="qa.win('maximize')"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/></svg></button>
283
+ <button class="ctl close" onclick="qa.win('close')"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
265
284
  </div>
266
- <div style="margin-left:auto"><span id="status" class="pill"><span class="dot"></span><span id="statusText">…</span></span></div>
267
285
  </div>
268
286
 
269
- <!-- Pairing -->
270
- <div id="pairCard" class="card hidden">
271
- <h2>CONNECT</h2>
272
- <div class="mut">Generate a pairing code on the Q-Agent app's <b>Local Agent</b> screen, then paste it here.</div>
273
- <label>Server URL</label>
274
- <input id="server" placeholder="https://your-qagent-server/api" />
275
- <label>Pair code</label>
276
- <textarea id="code" placeholder="paste the one-time pairing code"></textarea>
277
- <div class="err" id="pairErr"></div>
278
- <div class="row" style="margin-top:12px"><button id="connect" class="primary">Connect</button></div>
287
+ <!-- version tag (bottom-right) + update banner (desktop only; shown on demand) -->
288
+ <div id="versionTag" style="position:fixed;bottom:7px;right:11px;z-index:6;font-size:10px;font-weight:600;letter-spacing:.02em;color:#4e4e5e;font-family:'JetBrains Mono',ui-monospace,monospace;pointer-events:none">v&#8212;</div>
289
+ <div id="updateBar" style="display:none;position:fixed;left:14px;right:14px;bottom:16px;z-index:60;align-items:center;gap:11px;padding:11px 13px;border-radius:13px;background:rgba(24,24,32,.97);backdrop-filter:blur(22px);border:1px solid rgba(139,92,246,.4);box-shadow:0 20px 55px -18px rgba(0,0,0,.95);animation:fadeUp .4s ease both">
290
+ <span style="flex-shrink:0;width:30px;height:30px;border-radius:9px;background:rgba(139,92,246,.16);border:1px solid rgba(139,92,246,.32);display:flex;align-items:center;justify-content:center;color:#c4b5fd"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12M7 10l5 5 5-5M5 21h14"/></svg></span>
291
+ <div style="flex:1;min-width:0">
292
+ <div id="updTitle" style="font-size:12.5px;font-weight:700;color:#ECECF1;line-height:1.25">Update available</div>
293
+ <div id="updSub" style="font-size:11px;color:#9494a6;line-height:1.3;margin-top:1px"></div>
294
+ </div>
295
+ <button id="updBtn" style="flex-shrink:0;height:32px;padding:0 14px;border:none;border-radius:9px;font-family:inherit;font-size:12.5px;font-weight:700;color:#fff;background:linear-gradient(135deg,#8b5cf6,#6366f1);cursor:pointer;box-shadow:0 8px 22px -8px rgba(139,92,246,.8)">Install</button>
279
296
  </div>
280
297
 
281
- <!-- Connected -->
282
- <div id="connCard" class="card hidden">
283
- <div class="row">
284
- <div>
285
- <h2 style="margin-bottom:6px">DEVICE</h2>
286
- <div id="devName" style="font-weight:700"></div>
287
- <div class="mut" id="devServer"></div>
298
+ <!-- body -->
299
+ <div style="position:relative;z-index:2;flex:1;overflow-y:auto;padding:22px 22px 18px">
300
+ <div style="max-width:420px;margin:0 auto">
301
+
302
+ <!-- header -->
303
+ <div style="display:flex;align-items:flex-start;gap:14px;margin-bottom:26px">
304
+ <div style="position:relative;width:46px;height:46px;flex-shrink:0">
305
+ <span style="position:absolute;inset:-6px;border-radius:16px;background:radial-gradient(circle,rgba(139,92,246,.5),transparent 70%);filter:blur(8px);animation:logoHalo 3s ease-in-out infinite"></span>
306
+ <div style="position:relative;width:46px;height:46px;border-radius:15px;background:linear-gradient(135deg,#8b5cf6,#6366f1);display:flex;align-items:center;justify-content:center;box-shadow:0 10px 26px -8px rgba(139,92,246,.85)"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.3L19 10l-5.1 1.7L12 17l-1.9-5.3L5 10l5.1-1.7z"/></svg></div>
307
+ </div>
308
+ <div style="flex:1;min-width:0;padding-top:1px">
309
+ <div style="font-size:20px;font-weight:900;letter-spacing:-.02em;line-height:1.1">Local Agent</div>
310
+ <div style="font-size:12.5px;color:#8b8b9e;margin-top:4px;line-height:1.45">Runs Playwright on this machine — your session stays local.</div>
311
+ </div>
312
+ <div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
313
+ <div id="pill" style="display:flex;align-items:center;gap:7px;padding:6px 12px;border-radius:20px">
314
+ <span id="pillDot" style="width:7px;height:7px;border-radius:50%"></span>
315
+ <span id="pillLabel" style="font-size:11.5px;font-weight:700;white-space:nowrap"></span>
316
+ </div>
317
+ <button id="hdrDisconnect" title="Disconnect" style="display:none;align-items:center;justify-content:center;width:32px;height:32px;border-radius:10px;background:rgba(229,72,77,.12);border:1px solid rgba(229,72,77,.3);cursor:pointer;color:#ff8589"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"><path d="M18.36 6.64A9 9 0 1 1 5.64 6.64M12 2v10"/></svg></button>
288
318
  </div>
289
- <button id="disconnect" class="ghost" style="margin-left:auto">Disconnect</button>
290
319
  </div>
291
- </div>
292
320
 
293
- <div id="runCard" class="card hidden">
294
- <h2>ACTIVITY</h2>
295
- <div id="banner" class="banner"></div>
296
- <div id="progWrap" class="hidden">
297
- <div class="bar"><i id="progBar"></i></div>
298
- <div class="counts">
299
- <span style="color:var(--green)">✓ <b id="cPass">0</b></span>
300
- <span style="color:var(--red)">✗ <b id="cFail">0</b></span>
301
- <span class="mut"><b id="cRem">0</b> remaining</span>
321
+ <!-- CONNECT form -->
322
+ <div id="formView" style="display:none">
323
+ <div style="animation:fadeUp .5s ease both;border-radius:22px;background:rgba(20,20,28,.55);backdrop-filter:blur(28px);border:1px solid rgba(255,255,255,.08);padding:24px;box-shadow:0 24px 60px -22px rgba(0,0,0,.7)">
324
+ <div style="display:flex;align-items:center;gap:9px;margin-bottom:6px">
325
+ <span style="font-size:11px;font-weight:700;letter-spacing:.14em;color:#a78bfa">CONNECT</span>
326
+ <span style="flex:1;height:1px;background:linear-gradient(90deg,rgba(167,139,250,.35),transparent)"></span>
327
+ </div>
328
+ <p style="margin:0 0 20px;font-size:12.5px;color:#9494a6;line-height:1.5">Generate a pairing code on the Q‑Agent app's <span style="color:#c3c3d0;font-weight:600">Local Agent</span> screen, then enter it here.</p>
329
+
330
+ <!-- Compact "known server" line, shown when the server is baked in/paired
331
+ so the user only enters the 6-digit code. A "Change" link reveals the
332
+ full input (#serverBlock) for a different server. -->
333
+ <div id="serverKnown" style="display:none;align-items:center;gap:8px;margin-bottom:16px;padding:9px 12px;border-radius:11px;background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.07);font-size:12px;color:#9494a6">
334
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#7a7a8c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"/></svg>
335
+ <span style="color:#7a7a8c">Server</span>
336
+ <span id="serverHost" style="color:#c3c3d0;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
337
+ <a id="serverChange" href="#" style="margin-left:auto;flex-shrink:0;color:#a78bfa;text-decoration:none;font-weight:600">Change</a>
338
+ </div>
339
+
340
+ <div id="serverBlock" style="margin-bottom:16px">
341
+ <label style="display:block;font-size:12px;font-weight:600;color:#9494a6;margin-bottom:8px">Server URL</label>
342
+ <div id="urlBox" style="display:flex;align-items:center;gap:10px;height:46px;padding:0 14px;border-radius:12px;background:#16161f;border:1px solid rgba(255,255,255,.1);transition:border-color .18s">
343
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#7a7a8c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"/></svg>
344
+ <input id="server" placeholder="https://your-qagent-server/api" style="flex:1;min-width:0;background:none;border:none;color:#ECECF1;font-size:13.5px;font-family:inherit" />
345
+ </div>
346
+ </div>
347
+
348
+ <div style="margin-bottom:22px">
349
+ <label style="display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:600;color:#9494a6;margin-bottom:8px"><span>Pair code</span><span style="font-size:10.5px;font-weight:600;color:#6c6c7e">one-time · 6 digits</span></label>
350
+ <div style="position:relative">
351
+ <input id="code" inputmode="numeric" maxlength="6" style="position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:text;font-size:16px;z-index:2" />
352
+ <div style="display:flex;align-items:center;gap:11px;pointer-events:none">
353
+ <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:9px;flex:1">
354
+ <div class="cell" id="cell0"><span class="d"></span><span class="cr" style="display:none;width:2px;height:24px;background:#a78bfa;animation:caret 1.1s step-end infinite"></span></div>
355
+ <div class="cell" id="cell1"><span class="d"></span><span class="cr" style="display:none;width:2px;height:24px;background:#a78bfa;animation:caret 1.1s step-end infinite"></span></div>
356
+ <div class="cell" id="cell2"><span class="d"></span><span class="cr" style="display:none;width:2px;height:24px;background:#a78bfa;animation:caret 1.1s step-end infinite"></span></div>
357
+ </div>
358
+ <span style="width:13px;height:2.5px;border-radius:2px;background:#3a3a48;flex-shrink:0"></span>
359
+ <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:9px;flex:1">
360
+ <div class="cell" id="cell3"><span class="d"></span><span class="cr" style="display:none;width:2px;height:24px;background:#a78bfa;animation:caret 1.1s step-end infinite"></span></div>
361
+ <div class="cell" id="cell4"><span class="d"></span><span class="cr" style="display:none;width:2px;height:24px;background:#a78bfa;animation:caret 1.1s step-end infinite"></span></div>
362
+ <div class="cell" id="cell5"><span class="d"></span><span class="cr" style="display:none;width:2px;height:24px;background:#a78bfa;animation:caret 1.1s step-end infinite"></span></div>
363
+ </div>
364
+ </div>
365
+ </div>
366
+ </div>
367
+
368
+ <button id="connect" style="display:flex;align-items:center;justify-content:center;gap:9px;width:100%;height:47px;border-radius:13px;border:none;font-family:inherit;font-weight:700;font-size:14.5px;transition:transform .15s,box-shadow .2s">
369
+ <span id="connSpin" style="display:none;width:16px;height:16px;border:2px solid rgba(255,255,255,.35);border-top-color:#fff;border-radius:50%;animation:spin .7s linear infinite"></span>
370
+ <svg id="connBolt" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 3 14h7l-1 8 10-12h-7z"/></svg>
371
+ <span id="connLabel">Connect</span>
372
+ </button>
373
+ <div id="pairErr" style="margin-top:12px;font-size:12px;color:#ff9ea1;min-height:15px"></div>
374
+ </div>
375
+
376
+ <div style="display:flex;align-items:center;gap:8px;margin:16px 2px 14px;font-size:11.5px;color:#7a7a8c">
377
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#6ee7b7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V8a5 5 0 0 1 10 0v3"/></svg>
378
+ <span>Your session and credentials never leave this machine.</span>
379
+ </div>
380
+ <div id="envChips" style="display:flex;flex-wrap:wrap;gap:8px;padding:0 2px"></div>
381
+ </div>
382
+
383
+ <!-- CONNECTED -->
384
+ <div id="connView" style="display:none">
385
+ <div style="animation:fadeUp .5s ease both">
386
+ <div style="position:relative;overflow:hidden;border-radius:22px;background:linear-gradient(135deg,rgba(16,185,129,.12),rgba(20,20,28,.55));backdrop-filter:blur(28px);border:1px solid rgba(52,211,153,.28);padding:22px;box-shadow:0 24px 60px -22px rgba(0,0,0,.7);margin-bottom:14px">
387
+ <div style="display:flex;align-items:center;gap:13px">
388
+ <div style="width:42px;height:42px;border-radius:12px;background:rgba(52,211,153,.13);border:1px solid rgba(52,211,153,.28);display:flex;align-items:center;justify-content:center;flex-shrink:0"><svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg></div>
389
+ <div style="flex:1;min-width:0">
390
+ <div id="machineName" style="font-size:15px;font-weight:700;color:#eafff6;letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></div>
391
+ <div style="display:flex;align-items:center;gap:6px;margin-top:3px">
392
+ <span style="font-size:10px;font-weight:700;letter-spacing:.09em;color:#6c8c7e">SESSION</span>
393
+ <span id="sessionId" style="font-size:11.5px;color:#8fc7b3;font-family:'JetBrains Mono',monospace"></span>
394
+ </div>
395
+ </div>
396
+ </div>
397
+ </div>
398
+
399
+ <div style="border-radius:18px;background:rgba(8,8,13,.72);border:1px solid rgba(255,255,255,.08);overflow:hidden">
400
+ <div style="display:flex;align-items:center;gap:8px;padding:11px 15px;border-bottom:1px solid rgba(255,255,255,.06)">
401
+ <span style="width:7px;height:7px;border-radius:50%;background:#34d399;box-shadow:0 0 8px #34d399;animation:livePulse 1.6s ease-in-out infinite"></span>
402
+ <span style="font-size:11px;font-weight:700;letter-spacing:.06em;color:#9494a6">AGENT LOG</span>
403
+ <span style="margin-left:auto;display:flex;align-items:flex-end;gap:2px;height:12px">
404
+ <span style="width:2.5px;height:100%;border-radius:2px;background:#34d399;transform-origin:bottom;animation:barPulse 1s ease-in-out infinite"></span>
405
+ <span style="width:2.5px;height:100%;border-radius:2px;background:#34d399;transform-origin:bottom;animation:barPulse 1s ease-in-out infinite .2s"></span>
406
+ <span style="width:2.5px;height:100%;border-radius:2px;background:#34d399;transform-origin:bottom;animation:barPulse 1s ease-in-out infinite .4s"></span>
407
+ </span>
408
+ </div>
409
+ <div id="log" style="padding:13px 15px;font-family:'JetBrains Mono',monospace;font-size:11px;line-height:1.9;max-height:210px;overflow-y:auto"></div>
410
+ </div>
302
411
  </div>
303
412
  </div>
304
- <div class="feed" id="feed"></div>
413
+
414
+ </div>
305
415
  </div>
306
416
  </div>
307
417
 
308
418
  <script>
309
- const $ = (id) => document.getElementById(id);
310
- const time = (ts) => new Date(ts).toLocaleTimeString();
419
+ var qa = {};
420
+ var el = function(id){ return document.getElementById(id); };
421
+ var es = null;
422
+ var codeFocused = false;
311
423
 
312
- function setStatus(running, paired) {
313
- const dot = $("status").querySelector(".dot");
314
- if (!paired) { $("statusText").textContent = "Not connected"; dot.style.background = "#8b8b9e"; }
315
- else if (running) { $("statusText").textContent = "Running"; dot.style.background = "var(--green)"; }
316
- else { $("statusText").textContent = "Idle"; dot.style.background = "var(--amber)"; }
424
+ if (window.qagentDesktop) {
425
+ el("winctl").style.display = "flex";
426
+ qa.win = function(a){ try { window.qagentDesktop[a](); } catch(e){} };
427
+ } else {
428
+ qa.win = function(){};
317
429
  }
318
430
 
319
- function addEv(ev) {
320
- const feed = $("feed");
321
- const row = document.createElement("div");
322
- row.className = "ev";
323
- let m = "";
324
- switch (ev.type) {
325
- case "job-claimed": m = \`Claimed execution #\${ev.executionId} — run \${ev.runCode} (\${ev.total} spec\${ev.total===1?"":"s"})\`; resetProgress(ev.total); break;
326
- case "auth-waiting": showBanner(\`A browser opened on this machine — log in to continue.\${ev.url?" ("+ev.url+")":""}\`); m = "Waiting for manual login…"; break;
327
- case "auth-captured": hideBanner(); m = "Login captured — session saved locally."; break;
328
- case "case-running": m = \`Running \${ev.index}/\${ev.total} — \${ev.ticket||""} \${ev.caseCode||""}\`; break;
329
- case "case-result": {
330
- row.className = "ev " + (ev.status === "pass" ? "pass" : "fail");
331
- const b = document.createElement("span"); b.className = "badge " + (ev.status==="pass"?"pass":"fail"); b.textContent = ev.status.toUpperCase();
332
- m = \`\${ev.ticket||""} \${ev.caseCode||""}\`;
333
- row.appendChild(mkTime(ev.ts)); const mm = document.createElement("span"); mm.className="m"; mm.textContent=m; row.appendChild(mm); row.appendChild(b);
334
- feed.prepend(row); return;
431
+ // Explicit, user-driven update flow (desktop only). We ANNOUNCE a new version
432
+ // and only download/install when the user clicks — nothing happens silently.
433
+ (function(){
434
+ if (!window.qagentUpdate) return;
435
+ var bar = el("updateBar"), title = el("updTitle"), sub = el("updSub"), btn = el("updBtn");
436
+ var stage = null; // "download" (offer to download) | "install" (offer to restart)
437
+ function show(){ bar.style.display = "flex"; }
438
+ btn.addEventListener("click", function(){
439
+ if (stage === "install"){ window.qagentUpdate.install(); return; }
440
+ if (stage === "download"){
441
+ title.textContent = "Downloading update\\u2026"; sub.textContent = "0%";
442
+ btn.style.display = "none"; window.qagentUpdate.download();
335
443
  }
336
- case "progress": updateProgress(ev); return;
337
- case "job-complete": m = \`Execution #\${ev.executionId} complete — \${ev.passed} passed, \${ev.failed} failed\`; break;
338
- case "agent-status": setStatus(ev.running, true); return;
339
- case "error": row.className = "ev err"; m = ev.message || "Error"; break;
340
- case "log": m = ev.message || ""; break;
341
- default: return;
444
+ });
445
+ window.qagentUpdate.on(function(ch, d){
446
+ d = d || {};
447
+ if (ch === "update:available"){
448
+ stage = "download";
449
+ title.textContent = "Version " + d.version + " available";
450
+ sub.textContent = "A newer Local Agent is ready to install.";
451
+ btn.style.display = ""; btn.textContent = "Install"; show();
452
+ } else if (ch === "update:progress"){
453
+ sub.textContent = (d.percent || 0) + "% downloaded";
454
+ } else if (ch === "update:downloaded"){
455
+ stage = "install";
456
+ title.textContent = "Update ready";
457
+ sub.textContent = "Restart to finish installing v" + d.version + ".";
458
+ btn.style.display = ""; btn.textContent = "Restart & install"; show();
459
+ }
460
+ // update:none / update:error → leave the bar as-is (hidden or mid-flow).
461
+ });
462
+ // Check now (the renderer is already subscribed) and every 30 minutes after.
463
+ try { window.qagentUpdate.check(); } catch(e){}
464
+ setInterval(function(){ try { window.qagentUpdate.check(); } catch(e){} }, 1800000);
465
+ })();
466
+
467
+ var PILL = {
468
+ idle: { label:"Not connected", dot:"#7a7a8c", color:"#a0a0b2", bg:"rgba(255,255,255,.05)", border:"rgba(255,255,255,.1)", anim:"none" },
469
+ connecting: { label:"Connecting\\u2026", dot:"#a78bfa", color:"#c4b5fd", bg:"rgba(139,92,246,.16)", border:"rgba(139,92,246,.35)", anim:"livePulse 1.4s ease-in-out infinite" },
470
+ connected: { label:"Connected \\u00b7 Live", dot:"#34d399", color:"#6ee7b7", bg:"rgba(16,185,129,.14)", border:"rgba(52,211,153,.35)", anim:"livePulse 1.6s ease-in-out infinite" }
471
+ };
472
+ function setPill(k){
473
+ var p = PILL[k]; var box = el("pill");
474
+ box.style.background = p.bg; box.style.border = "1px solid " + p.border;
475
+ el("pillDot").style.background = p.dot; el("pillDot").style.boxShadow = "0 0 8px " + p.dot; el("pillDot").style.animation = p.anim;
476
+ el("pillLabel").textContent = p.label; el("pillLabel").style.color = p.color;
477
+ }
478
+
479
+ function code(){ return el("code").value; }
480
+ function canConnect(){ return el("server").value.trim() && code().length === 6; }
481
+ function paintConnectBtn(){
482
+ var b = el("connect"), ok = canConnect();
483
+ if (ok) { b.style.background = "linear-gradient(135deg,#8b5cf6,#6366f1)"; b.style.color = "#fff"; b.style.cursor = "pointer"; b.style.boxShadow = "0 12px 30px -10px rgba(139,92,246,.8)"; }
484
+ else { b.style.background = "rgba(255,255,255,.06)"; b.style.color = "#6c6c7e"; b.style.cursor = "not-allowed"; b.style.boxShadow = "none"; }
485
+ }
486
+ function renderCells(){
487
+ var v = code();
488
+ for (var i=0;i<6;i++){
489
+ var cell = el("cell"+i);
490
+ var filled = i < v.length;
491
+ var active = codeFocused && i === v.length && v.length < 6;
492
+ cell.style.border = active ? "1.5px solid #8b5cf6" : (filled ? "1.5px solid rgba(255,255,255,.16)" : "1.5px solid rgba(255,255,255,.09)");
493
+ cell.style.boxShadow = active ? "0 0 0 3px rgba(139,92,246,.18)" : "none";
494
+ cell.querySelector(".d").textContent = v[i] || "";
495
+ cell.querySelector(".cr").style.display = active ? "inline-block" : "none";
496
+ }
497
+ }
498
+
499
+ function timeStr(ts){ return new Date(ts).toTimeString().slice(0,8); }
500
+ function logColor(t){
501
+ if (t === "error" || t === "auth-error") return "#ff9ea1";
502
+ if (t === "auth-waiting") return "#fbbf24";
503
+ if (t === "auth-captured") return "#6ee7b7";
504
+ if (t === "job-claimed") return "#c4b5fd";
505
+ if (t === "job-complete") return "#6ee7b7";
506
+ return "#8b8b9e";
507
+ }
508
+ function logMsg(ev){
509
+ switch (ev.type){
510
+ case "job-claimed": return "claimed execution #" + ev.executionId + " \\u00b7 run " + ev.runCode + " (" + ev.total + " spec" + (ev.total===1?"":"s") + ")";
511
+ case "auth-waiting": return "waiting for manual login\\u2026" + (ev.url?" ("+ev.url+")":"");
512
+ case "auth-captured": return "login captured \\u00b7 session saved locally";
513
+ case "case-running": return "running " + ev.index + "/" + ev.total + " \\u00b7 " + (ev.ticket||"") + " " + (ev.caseCode||"");
514
+ case "case-result": return (ev.status==="pass"?"\\u2713":"\\u2717") + " " + (ev.ticket||"") + " " + (ev.caseCode||"");
515
+ case "progress": return null;
516
+ case "job-complete": return "execution #" + ev.executionId + " complete \\u00b7 " + ev.passed + " passed, " + ev.failed + " failed";
517
+ case "error": return ev.message || "error";
518
+ case "log": return ev.message || "";
519
+ default: return null;
342
520
  }
343
- if (!m) return;
344
- row.appendChild(mkTime(ev.ts));
345
- const mm = document.createElement("span"); mm.className = "m"; mm.textContent = m; row.appendChild(mm);
346
- feed.prepend(row);
347
521
  }
348
- function mkTime(ts){ const t=document.createElement("span"); t.className="t"; t.textContent=time(ts); return t; }
349
- function resetProgress(total){ $("progWrap").classList.remove("hidden"); $("progBar").style.width="0%"; $("cPass").textContent="0"; $("cFail").textContent="0"; $("cRem").textContent=String(total||0); }
350
- function updateProgress(ev){ $("progWrap").classList.remove("hidden"); $("progBar").style.width=(ev.progress||0)+"%"; $("cPass").textContent=ev.passed; $("cFail").textContent=ev.failed; $("cRem").textContent=ev.remaining; }
351
- function showBanner(t){ const b=$("banner"); b.textContent=t; b.style.display="block"; }
352
- function hideBanner(){ $("banner").style.display="none"; }
522
+ function addLog(ev){
523
+ if (ev.type === "agent-status") return; // status events aren't log lines; pill is driven by view state
524
+ var msg = logMsg(ev); if (!msg) return;
525
+ var row = document.createElement("div");
526
+ row.style.cssText = "display:flex;gap:10px;animation:logIn .4s ease both";
527
+ var t = document.createElement("span"); t.style.cssText = "color:#5c5c6e;flex-shrink:0"; t.textContent = timeStr(ev.ts);
528
+ var m = document.createElement("span"); m.style.color = logColor(ev.type); m.textContent = msg;
529
+ row.appendChild(t); row.appendChild(m);
530
+ var log = el("log"); log.appendChild(row); log.scrollTop = log.scrollHeight;
531
+ }
532
+ function openStream(){ if (es) return; es = new EventSource("/api/events"); es.onmessage = function(e){ try { addLog(JSON.parse(e.data)); } catch(x){} }; }
533
+ function closeStream(){ if (es){ es.close(); es = null; } }
353
534
 
354
- let es = null;
355
- function openStream(){ if (es) return; es = new EventSource("/api/events"); es.onmessage = (e)=>{ try{ addEv(JSON.parse(e.data)); }catch{} }; }
535
+ function renderChips(s){
536
+ var chips = [
537
+ { name:"Playwright", ver:s.playwrightVersion || "\\u2014", dot:"#34d399" },
538
+ { name:"Node", ver:(s.nodeVersion||"").replace(/^v/,"") || "\\u2014", dot:"#6ee7b7" },
539
+ { name:"Chromium", ver:"auto", dot:"#60a5fa" }
540
+ ];
541
+ var html = "";
542
+ for (var i=0;i<chips.length;i++){
543
+ var c = chips[i];
544
+ html += '<span style="display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:9px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.07);font-size:10.5px;color:#9494a6">'
545
+ + '<span style="width:6px;height:6px;border-radius:50%;background:' + c.dot + '"></span>'
546
+ + '<span style="color:#c3c3d0;font-weight:600">' + c.name + '</span>'
547
+ + '<span style="font-family:\\'JetBrains Mono\\',monospace;color:#7a7a8c">' + c.ver + '</span></span>';
548
+ }
549
+ el("envChips").innerHTML = html;
550
+ }
356
551
 
357
- async function refresh() {
358
- const s = await (await fetch("/api/state")).json();
359
- setStatus(s.running, s.paired);
360
- if (s.paired) {
361
- $("pairCard").classList.add("hidden");
362
- $("connCard").classList.remove("hidden"); $("runCard").classList.remove("hidden");
363
- $("devName").textContent = (s.deviceName||"This device") + " · #" + s.deviceId;
364
- $("devServer").textContent = s.serverUrl;
365
- $("feed").innerHTML = ""; (s.events||[]).forEach(addEv);
366
- openStream();
552
+ function hostOf(u){ try { return new URL(u).host; } catch(e) { return u; } }
553
+ function applyServerView(){
554
+ // A known server (baked into the build, or from a prior pairing) collapses to
555
+ // a compact line so the user only deals with the 6-digit code; empty shows the
556
+ // full URL input.
557
+ var known = el("server").value.trim();
558
+ if (known){
559
+ el("serverKnown").style.display = "flex";
560
+ el("serverBlock").style.display = "none";
561
+ el("serverHost").textContent = hostOf(known);
367
562
  } else {
368
- $("pairCard").classList.remove("hidden");
369
- $("connCard").classList.add("hidden"); $("runCard").classList.add("hidden");
370
- if (s.serverUrl) $("server").value = s.serverUrl;
563
+ el("serverKnown").style.display = "none";
564
+ el("serverBlock").style.display = "block";
371
565
  }
372
566
  }
567
+ function showForm(s){
568
+ el("formView").style.display = "block"; el("connView").style.display = "none";
569
+ el("hdrDisconnect").style.display = "none";
570
+ closeStream(); setPill("idle"); renderChips(s);
571
+ if (s.serverUrl) el("server").value = s.serverUrl;
572
+ applyServerView();
573
+ renderCells(); paintConnectBtn();
574
+ }
575
+ function showConnected(s){
576
+ el("formView").style.display = "none"; el("connView").style.display = "block";
577
+ el("hdrDisconnect").style.display = "flex";
578
+ setPill("connected");
579
+ el("machineName").textContent = s.machine || "this machine";
580
+ el("sessionId").textContent = "device #" + (s.deviceId==null?"?":s.deviceId);
581
+ // The SSE stream replays the recent-events buffer on connect, so seed the log
582
+ // ONLY from the stream (seeding from /api/state too would double every line).
583
+ el("log").innerHTML = ""; openStream();
584
+ }
585
+ function refresh(){
586
+ fetch("/api/state").then(function(r){ return r.json(); }).then(function(s){
587
+ if (s.appVersion) el("versionTag").textContent = "v" + s.appVersion;
588
+ if (s.paired) showConnected(s); else showForm(s);
589
+ });
590
+ }
373
591
 
374
- $("connect").onclick = async () => {
375
- const btn = $("connect"); btn.disabled = true; $("pairErr").textContent = "";
376
- try {
377
- const r = await fetch("/api/pair", { method:"POST", headers:{"Content-Type":"application/json"},
378
- body: JSON.stringify({ code: $("code").value.trim(), server: $("server").value.trim() }) });
379
- const j = await r.json();
380
- if (!r.ok) throw new Error(j.error || "Pairing failed");
381
- await refresh();
382
- } catch (e) { $("pairErr").textContent = e.message; }
383
- finally { btn.disabled = false; }
384
- };
385
- $("disconnect").onclick = async () => { await fetch("/api/disconnect", { method:"POST" }); if (es){es.close(); es=null;} await refresh(); };
592
+ el("server").addEventListener("input", paintConnectBtn);
593
+ el("server").addEventListener("focus", function(){ el("urlBox").style.borderColor = "rgba(139,92,246,.55)"; });
594
+ el("server").addEventListener("blur", function(){ el("urlBox").style.borderColor = "rgba(255,255,255,.1)"; });
595
+ el("serverChange").addEventListener("click", function(e){
596
+ e.preventDefault();
597
+ el("serverKnown").style.display = "none";
598
+ el("serverBlock").style.display = "block";
599
+ el("server").focus();
600
+ });
601
+ el("code").addEventListener("input", function(){
602
+ el("code").value = el("code").value.replace(/[^0-9]/g,"").slice(0,6);
603
+ renderCells(); paintConnectBtn();
604
+ });
605
+ el("code").addEventListener("focus", function(){ codeFocused = true; renderCells(); });
606
+ el("code").addEventListener("blur", function(){ codeFocused = false; renderCells(); });
607
+
608
+ el("connect").addEventListener("click", function(){
609
+ if (!canConnect()) return;
610
+ el("pairErr").textContent = "";
611
+ el("connSpin").style.display = "block"; el("connBolt").style.display = "none";
612
+ el("connLabel").textContent = "Connecting\\u2026"; setPill("connecting");
613
+ fetch("/api/pair", { method:"POST", headers:{"Content-Type":"application/json"},
614
+ body: JSON.stringify({ code: code().trim(), server: el("server").value.trim() }) })
615
+ .then(function(r){ return r.json().then(function(j){ return { ok:r.ok, j:j }; }); })
616
+ .then(function(res){ if (!res.ok) throw new Error(res.j.error || "Pairing failed"); refresh(); })
617
+ .catch(function(e){ el("pairErr").textContent = e.message; setPill("idle"); })
618
+ .then(function(){
619
+ el("connSpin").style.display = "none"; el("connBolt").style.display = "block"; el("connLabel").textContent = "Connect";
620
+ });
621
+ });
622
+ el("hdrDisconnect").addEventListener("click", function(){
623
+ fetch("/api/disconnect", { method:"POST" }).then(function(){ closeStream(); refresh(); });
624
+ });
386
625
 
387
626
  refresh();
388
627
  </script>