nio-cat 0.5.0 → 0.6.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/web/app/app.js CHANGED
@@ -12,8 +12,17 @@ const isIOS = /iP(hone|ad|od)/.test(navigator.userAgent) || (navigator.platform
12
12
  const standalone = matchMedia('(display-mode: standalone)').matches || navigator.standalone;
13
13
  const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
14
14
  const bot = (mood = 'idle', cls = '') => (window.NioBot ? NioBot.html(mood, cls) : '');
15
+ const CHIP_ICON = {
16
+ mode: '<path d="M12 3l7 3v5c0 4.5-3 8-7 10-4-2-7-5.5-7-10V6z"/>',
17
+ folder: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
18
+ model: '<rect x="6" y="6" width="12" height="12" rx="2.5"/><path d="M9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3"/>',
19
+ cost: '<circle cx="12" cy="12" r="9"/><path d="M14.6 9.3c-.6-.8-1.500-1.300-2.600-1.300-1.400 0-2.500.8-2.500 2 0 2.700 5 1.300 5 4 0 1.200-1.100 2-2.500 2-1.100 0-2-.5-2.600-1.300M12 6.500V8M12 16v1.500"/>'
20
+ };
15
21
  const svg = (d) => `<svg class="ico" viewBox="0 0 24 24" aria-hidden="true">${d}</svg>`;
16
22
  const ICON = {
23
+ activity: '<path d="M3 12h4l3-7 4 14 3-7h4"/>',
24
+ jobs: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8 9h8M8 13h5M8 17h3"/>',
25
+ memory: '<path d="M6 4h9l3 3v13H6z"/><path d="M9 11h6M9 15h6M14 4v4h4"/>',
17
26
  sessions: '<path d="M4 6h16M4 12h16M4 18h10"/>',
18
27
  approvals: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
19
28
  phone: '<rect x="6" y="3" width="12" height="18" rx="3"/><path d="M10.5 17.5h3"/>',
@@ -46,7 +55,7 @@ const MODES = {
46
55
  };
47
56
 
48
57
  /* ---------------------------------------------------------------- estat */
49
- const state = { route: { name: 'sessions' }, ollama: null, info: null, connected: false, lastError: null, sessions: new Map(), approvals: new Map(), events: new Map(), loaded: new Set() };
58
+ const state = { route: { name: 'sessions' }, ollama: null, info: null, connected: false, lastError: null, sessions: new Map(), approvals: new Map(), events: new Map(), loaded: new Set(), synced: false };
50
59
  const LS = {
51
60
  get(k, d) { try { const v = localStorage.getItem(k); return v == null ? d : JSON.parse(v); } catch { return d; } },
52
61
  set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* emmagatzematge bloquejat */ } }
@@ -137,9 +146,81 @@ async function api(path, opts = {}) {
137
146
  return data;
138
147
  }
139
148
 
140
- let streamCtrl = null, retry = 0;
149
+ let streamCtrl = null, retry = 0, gotHello = false;
150
+ async function pollLoop(ctrl) {
151
+ let first = true;
152
+ while (!ctrl.signal.aborted) {
153
+ try {
154
+ const [list, aps] = await Promise.all([api('/api/sessions'), api('/api/approvals')]);
155
+ if (ctrl.signal.aborted) return;
156
+ state.connected = true; state.lastError = null;
157
+ if (first) { first = false; state.synced = true; const r = state.route; state.sessions = new Map(list.map((x) => [x.id, x])); state.approvals = new Map(aps.map((a) => [a.id, a])); if (r.name === 'session') openSession(r.id); else render(); }
158
+ else {
159
+ const r = state.route, seen = new Set();
160
+ for (const x of list) {
161
+ seen.add(x.id); const old = state.sessions.get(x.id);
162
+ if (!old || old.updated !== x.updated || old.state !== x.state || old.pending !== x.pending || old.title !== x.title) handle({ type: 'session', session: x });
163
+ }
164
+ for (const id of [...state.sessions.keys()]) if (!seen.has(id)) handle({ type: 'removed', session: id });
165
+ for (const a of aps) if (!state.approvals.has(a.id)) handle({ type: 'approval', approval: a });
166
+ for (const id of [...state.approvals.keys()]) if (!aps.some((a) => a.id === id)) { state.approvals.delete(id); renderChrome(); if (r.name === 'approvals') render(); }
167
+ const evs = r.name === 'session' && state.events.get(r.id);
168
+ if (evs && state.sessions.has(r.id)) {
169
+ const since = Math.max(0, ...evs.keys());
170
+ const d = await api(`/api/sessions/${r.id}?since=${since}`);
171
+ for (const e of d.events) if (!evs.has(e.seq)) handle({ type: 'event', session: r.id, event: e });
172
+ }
173
+ }
174
+ } catch (e) {
175
+ if (ctrl.signal.aborted) return;
176
+ state.connected = false; state.lastError = 'No arribo a l’ordinador'; render();
177
+ return setTimeout(() => machine() && !ctrl.signal.aborted && connect(), 3000);
178
+ }
179
+ await new Promise((r) => setTimeout(r, 2000));
180
+ }
181
+ }
182
+ /* ---------- Adreça recordada a nio.cat
183
+ Cada ordinador publica la seva adreça actual a nio.cat, signada amb una clau Ed25519 pròpia.
184
+ Si el túnel canvia d'adreça i no hi arribem, la busquem allà i només la fem servir si la
185
+ signatura és de la clau que vam guardar en aparellar-nos (nio.cat no pot enganyar-nos). */
186
+ const DIRECTORY = 'https://www.nio.cat';
187
+ function rememberIdentity(url, ident) {
188
+ if (!ident?.pub || !ident?.loc) return;
189
+ const ms = store.machines, m = ms.find((x) => x.url === url);
190
+ if (!m || m.pub === ident.pub) return;
191
+ if (m.pub) return; // una altra clau a la mateixa adreça: no la canviem mai sola
192
+ m.pub = ident.pub; m.loc = ident.loc; store.machines = ms;
193
+ }
194
+ const b64uBytes = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (s.length % 4)) % 4)), (c) => c.charCodeAt(0));
195
+ async function signedBy(rec, m) {
196
+ if (rec.pub !== m.pub) return false;
197
+ try {
198
+ const key = await crypto.subtle.importKey('raw', b64uBytes(m.pub), { name: 'Ed25519' }, false, ['verify']);
199
+ return await crypto.subtle.verify({ name: 'Ed25519' }, key, b64uBytes(rec.sig), new TextEncoder().encode(`nio-node-v1\n${m.loc}\n${rec.url}\n${rec.t}`));
200
+ } catch {
201
+ // Navegador sense Ed25519 (iOS < 17, Chrome < 113): ens refiem de la comprovació que fa nio.cat.
202
+ return true;
203
+ }
204
+ }
205
+ const relocating = new Map();
206
+ async function relocate(i) {
207
+ const ms = store.machines, m = ms[i];
208
+ if (!m?.loc || Date.now() - (relocating.get(m.loc) || 0) < 15000) return false;
209
+ relocating.set(m.loc, Date.now());
210
+ try {
211
+ const res = await fetch(`${DIRECTORY}/api/node?id=${encodeURIComponent(m.loc)}`, { cache: 'no-store', signal: AbortSignal.timeout(6000) });
212
+ if (!res.ok) return false;
213
+ const rec = await res.json();
214
+ if (!rec.url || rec.url === m.url || (m.locT && rec.t <= m.locT) || !/^https:\/\//.test(rec.url) || !(await signedBy(rec, m))) return false;
215
+ const fresh = store.machines, x = fresh.find((y) => y.loc === m.loc); if (!x) return false;
216
+ x.url = rec.url; x.locT = rec.t; store.machines = fresh;
217
+ if (i === store.active) toast('L’ordinador ha canviat d’adreça. Ja hi torno a ser.');
218
+ return true;
219
+ } catch { return false; }
220
+ }
221
+
141
222
  async function connect() {
142
- streamCtrl?.abort();
223
+ streamCtrl?.abort(); state.synced = false;
143
224
  const m = machine();
144
225
  if (!m) { state.connected = false; return render(); }
145
226
  const ctrl = new AbortController(); streamCtrl = ctrl;
@@ -149,10 +230,14 @@ async function connect() {
149
230
  if (res.status === 401) throw Object.assign(new Error('La clau no és vàlida. Torna a connectar amb el codi QR.'), { fatal: true });
150
231
  if (!res.ok || !res.body) throw new Error(`Error ${res.status}`);
151
232
  state.connected = true; state.lastError = null; retry = 0;
233
+ rememberIdentity(m.url, state.info.identity);
152
234
  renameMachine(state.info.host, state.info.id); syncServiceWorker(); render(); brainsForBoard();
153
235
  const reader = res.body.getReader(), dec = new TextDecoder(); let buf = '';
236
+ // Alguns túnels (Cloudflare des d'alguns servidors) no deixen passar el flux en directe: sense «hello» en 5 s, es passa a consultar cada 2 s.
237
+ gotHello = false; let poll = false;
238
+ const hw = setTimeout(() => { if (!gotHello) { poll = true; reader.cancel().catch(() => {}); } }, 5000);
154
239
  for (;;) {
155
- const { value, done } = await reader.read(); if (done) break;
240
+ const { value, done } = await reader.read().catch(() => ({ done: true })); if (done) break;
156
241
  buf += dec.decode(value, { stream: true }); let i;
157
242
  while ((i = buf.indexOf('\n\n')) >= 0) {
158
243
  const block = buf.slice(0, i); buf = buf.slice(i + 2);
@@ -160,6 +245,8 @@ async function connect() {
160
245
  if (data) handle(JSON.parse(data));
161
246
  }
162
247
  }
248
+ clearTimeout(hw);
249
+ if (poll) return pollLoop(ctrl);
163
250
  throw new Error('S’ha tallat la connexió');
164
251
  } catch (e) {
165
252
  if (ctrl.signal.aborted) return;
@@ -167,6 +254,8 @@ async function connect() {
167
254
  state.lastError = e.message === 'Failed to fetch' || e.name === 'TypeError' ? (location.protocol === 'https:' && /^http:\/\/(127\.0\.0\.1|localhost)/.test(m.url) ? 'El navegador no deixa que aquesta web parli amb aquest ordinador. Prova Chrome, o engega nio amb --local.' : 'No arribo a l’ordinador') : e.message;
168
255
  render();
169
256
  if (e.fatal) return;
257
+ // Potser el túnel ha canviat d'adreça: la busquem a nio.cat (signada per l'ordinador).
258
+ if (state.lastError === 'No arribo a l’ordinador' && (await relocate(store.active))) { if (!ctrl.signal.aborted) connect(); return; }
170
259
  if (m.kind === 'vps' && m.via && retry >= 2) autoReconnect(m);
171
260
  setTimeout(() => machine() && !ctrl.signal.aborted && connect(), Math.min(15000, 1000 * 2 ** retry++));
172
261
  }
@@ -213,6 +302,7 @@ function handle(msg) {
213
302
  if (!isPhone() && msg.device !== deviceId()) { if ($('#qrBox')) closeSheet(); toast('Mòbil connectat correctament', 2000); }
214
303
  break;
215
304
  case 'hello':
305
+ gotHello = true; state.synced = true;
216
306
  state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
217
307
  state.approvals = new Map(msg.approvals.map((a) => [a.id, a]));
218
308
  state.loaded.clear(); state.events.clear();
@@ -234,6 +324,7 @@ function handle(msg) {
234
324
  break;
235
325
  }
236
326
  case 'ollama': onOllama(msg); break;
327
+ case 'job': onJob(msg); break;
237
328
  case 'approval':
238
329
  if (msg.approval.state === 'pending') { state.approvals.set(msg.approval.id, msg.approval); if (!document.hidden && navigator.vibrate) navigator.vibrate(30); }
239
330
  else if (state.approvals.has(msg.approval.id)) { markDecided(msg.approval.id, msg.approval.state); break; }
@@ -281,6 +372,30 @@ function addMachine({ url, token, name, kind, via }) {
281
372
  state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.info = null; state.ollama = null;
282
373
  connect();
283
374
  }
375
+ // El codi o l'enllaç porta un secret d'un sol ús: el canviem per una clau pròpia d'aquest dispositiu,
376
+ // que es pot revocar des d'Ordinadors. Si és la clau principal (el panell que obre nio) o un nio antic
377
+ // que encara no fa aparellament, el valor ja és una clau i el fem servir tal qual.
378
+ function deviceName() {
379
+ const ua = navigator.userAgent;
380
+ const dev = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) || (isIOS && !/iPhone/.test(ua)) ? 'iPad' : /Android/.test(ua) ? (ua.match(/Android[^;]*;\s*([^;)]+?)(?:\sBuild|\))/)?.[1] || 'Android') : /Mac/.test(ua) ? 'Mac' : /Windows/.test(ua) ? 'Windows' : /Linux/.test(ua) ? 'Linux' : 'Dispositiu';
381
+ const br = /Edg\//.test(ua) ? 'Edge' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : '';
382
+ return `${dev}${br ? ` · ${br}` : ''}${standalone ? ' (app)' : ''}`.slice(0, 60);
383
+ }
384
+ async function claimPair(pair) {
385
+ let res = null;
386
+ try { res = await fetch(`${pair.url}/api/pair`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ code: pair.token, name: deviceName() }), cache: 'no-store' }); }
387
+ catch { return pair; } // sense connexió ara: ho resoldrà la connexió
388
+ if (res.ok) { const d = await res.json(); return { ...pair, token: d.token, name: pair.name || d.host, identity: d.identity }; }
389
+ const why = (await res.json().catch(() => ({}))).error;
390
+ const ok = await fetch(`${pair.url}/api/info`, { headers: { Authorization: `Bearer ${pair.token}` }, cache: 'no-store' }).then((r) => r.ok).catch(() => false);
391
+ if (ok) return pair;
392
+ throw new Error(why || 'Aquest codi no és vàlid o ha caducat. Demana’n un de nou amb «nio pair».');
393
+ }
394
+ async function pairAndAdd(pair, { msg = 'Ordinador connectat', after } = {}) {
395
+ try { const p = await claimPair(pair); addMachine(p); rememberIdentity(p.url, p.identity); toast(msg); after?.(); }
396
+ catch (e) { toast(e.message, 6000); }
397
+ render();
398
+ }
284
399
  // Passa a un altre ordinador de la llista i es queda a la mateixa secció.
285
400
  function switchMachine(i) {
286
401
  closeSheet(); store.active = i; streamCtrl?.abort();
@@ -300,9 +415,9 @@ function checkPairHash() {
300
415
  history.replaceState(null, '', location.pathname + '#/sessions');
301
416
  state.route = { name: 'sessions' };
302
417
  const wantQr = new URLSearchParams(location.hash.replace(/^#/, '')).get('qr') === '1' || /[#&]qr=1\b/.test(hashAtStart);
303
- addMachine(pair); render(); toast('Ordinador connectat');
418
+ render();
304
419
  // Quan nio obre el panell, el QR per al mòbil surt tot seguit, sense cap pestanya nova.
305
- if (wantQr && !isPhone()) setTimeout(mobileQrSheet, 500);
420
+ pairAndAdd(pair, { after: () => { if (wantQr && !isPhone()) setTimeout(mobileQrSheet, 500); } });
306
421
  return true;
307
422
  }
308
423
 
@@ -310,6 +425,9 @@ function checkPairHash() {
310
425
  const VIEWS = {
311
426
  sessions: { label: 'Sessions', icon: ICON.sessions, group: 'Feina' },
312
427
  approvals: { label: 'Aprovacions', icon: ICON.approvals, group: 'Feina' },
428
+ feines: { label: 'Feines', icon: ICON.jobs, group: 'Feina' },
429
+ activitat: { label: 'Activitat', icon: ICON.activity, group: 'Feina' },
430
+ memoria: { label: 'Memòria', icon: ICON.memory, group: 'Ordinadors' },
313
431
  ordinadors: { label: 'Ordinadors', icon: ICON.machine, group: 'Ordinadors' },
314
432
  models: { label: 'Models', icon: ICON.models, group: 'Ordinadors' },
315
433
  mobil: { label: 'Xat local', icon: ICON.phone, group: 'Eines' },
@@ -317,8 +435,8 @@ const VIEWS = {
317
435
  ajustos: { label: 'Ajustos', icon: ICON.settings, group: 'Compte' },
318
436
  ajuda: { label: 'Ajuda', icon: ICON.help, group: 'Compte' }
319
437
  };
320
- const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', ordinadors: '#/ordinadors', models: '#/models', mobil: '#/xat-local', pissarra: '#/pissarra', ajustos: '#/ajustos', ajuda: '#/ajuda' };
321
- const NEEDS_MACHINE = new Set(['sessions', 'approvals', 'models', 'session']);
438
+ const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', feines: '#/feines', activitat: '#/activitat', memoria: '#/memoria', ordinadors: '#/ordinadors', models: '#/models', mobil: '#/xat-local', pissarra: '#/pissarra', ajustos: '#/ajustos', ajuda: '#/ajuda' };
439
+ const NEEDS_MACHINE = new Set(['sessions', 'approvals', 'feines', 'activitat', 'memoria', 'models', 'session']);
322
440
  function parseRoute() {
323
441
  const h = location.hash.replace(/^#\/?/, '');
324
442
  if (h.startsWith('s/')) return { name: 'session', id: h.slice(2) };
@@ -363,6 +481,7 @@ function renderChrome() {
363
481
  }
364
482
  function render() {
365
483
  const r = state.route, view = $('#view'), board = r.name === 'pissarra';
484
+ if (r.name !== 'sessions') manage = null;
366
485
  renderChrome();
367
486
  document.body.classList.toggle('board-mode', board);
368
487
  document.body.classList.toggle('chat-mode', r.name === 'session' || (r.name === 'mobil' && !!local.engine));
@@ -375,22 +494,43 @@ function render() {
375
494
  else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
376
495
  else {
377
496
  const html = r.name === 'mobil' ? viewLocal() : null;
378
- view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html : r.name === 'ordinadors' ? viewComputers() : r.name === 'sessions' ? viewSessions() : r.name === 'approvals' ? viewApprovals() : r.name === 'ajustos' ? viewSettings() : r.name === 'ajuda' ? viewHelp() : viewModels()}</div>`;
497
+ view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html : r.name === 'ordinadors' ? viewComputers() : r.name === 'sessions' ? viewSessions() : r.name === 'approvals' ? viewApprovals() : r.name === 'activitat' ? viewActivity() : r.name === 'feines' ? viewJobs() : r.name === 'memoria' ? viewMemory() : r.name === 'ajustos' ? viewSettings() : r.name === 'ajuda' ? viewHelp() : viewModels()}</div>`;
379
498
  if (r.name === 'mobil') bindLocal();
380
499
  if (r.name === 'models' && machine()) loadOllama();
500
+ if (r.name === 'activitat' && machine()) { activity = null; loadActivity(); }
501
+ if (r.name === 'memoria' && machine()) loadMemory();
502
+ if (r.name === 'feines' && machine()) loadJobs();
503
+ hydrateShots();
381
504
  }
382
505
  if (changed && !board) { view.classList.remove('view-enter'); void view.offsetWidth; view.classList.add('view-enter'); }
383
506
  window.NioBot?.refresh();
384
507
  tourUpdate();
508
+ if (!board) setTimeout(focusMain, 60);
385
509
  }
386
510
 
387
511
  /* ---------- Ordinadors (llista, afegir i detall de l'actiu) */
388
512
  const deviceId = () => { let d = LS.get('nio.device', ''); if (!d) { d = Math.random().toString(36).slice(2, 12); LS.set('nio.device', d); } return d; };
389
513
  const isPhone = () => isTouch || matchMedia('(max-width: 820px)').matches;
390
514
  const cmdBox = (c) => `<div class="cmd big"><code>${esc(c)}</code><button class="btn sm" data-copy="${esc(c)}" type="button">${svg(ICON.copy)}Copia</button></div>`;
515
+ // Estat de la resta d'ordinadors (el connectat ja té el seu flux en directe): una consulta lleugera.
516
+ const reach = new Map();
517
+ let reachAt = 0;
518
+ async function probeAll(force) {
519
+ if (!force && Date.now() - reachAt < 20000) return;
520
+ reachAt = Date.now();
521
+ await Promise.all(store.machines.map(async (m, i) => {
522
+ if (i === store.active) return;
523
+ let ok = await fetch(`${m.url}/api/info`, { headers: { Authorization: `Bearer ${m.token}` }, cache: 'no-store', signal: AbortSignal.timeout(5000) }).then((r) => r.ok).catch(() => false);
524
+ if (!ok && (await relocate(i))) { reach.set(store.machines[i].url, true); return render(); }
525
+ if (reach.get(m.url) !== ok) { reach.set(m.url, ok); if (state.route.name === 'ordinadors') { const y = scrollY; render(); scrollTo(0, y); } }
526
+ }));
527
+ }
528
+ setInterval(() => { if (!document.hidden && state.route.name === 'ordinadors') probeAll(); }, 30000);
391
529
  function computerCard(m, i) {
392
530
  const on = i === store.active;
393
- const st = on ? (state.connected ? '<span class="chip ok"><i class="dot on"></i>Connectat</span>' : `<span class="chip">${esc(state.lastError ? 'Sense connexió' : 'Connectant…')}</span>`) : '';
531
+ const pr = reach.get(m.url);
532
+ const st = on ? (state.connected ? '<span class="chip ok"><i class="dot on"></i>Connectat</span>' : `<span class="chip">${esc(state.lastError ? 'Sense connexió' : 'Connectant…')}</span>`)
533
+ : pr == null ? '' : pr ? '<span class="chip ok"><i class="dot on"></i>En línia</span>' : '<span class="chip"><i class="dot"></i>Fora de línia</span>';
394
534
  return `<div class="card computer ${on ? 'active' : ''}">
395
535
  <div class="row" style="flex-wrap:nowrap;gap:12px;align-items:flex-start"><span class="c-ico">${svg(machineIcon(m))}</span><div class="grow"><div class="row between"><b>${esc(m.name)}</b>${st}</div><div class="muted small">${esc(machineSub(m))}</div></div></div>
396
536
  <div class="row c-actions">${on ? `<button class="btn ink sm" data-act="open-sessions" type="button">Sessions</button>${isPhone() ? '' : `<button class="btn sm" data-act="mobile-qr" type="button">Continua al mòbil</button>`}` : `<button class="btn ink sm" data-machine="${i}" type="button">Obre</button>`}${m.kind === 'vps' && m.via && (!on || !state.connected) ? `<button class="btn sm" data-reconnect="${i}" type="button">Torna a connectar</button>` : ''}<button class="btn ghost sm" data-rename="${i}" type="button">Canvia el nom</button><button class="btn ghost sm" data-forget="${i}" type="button">Oblida</button></div>
@@ -408,9 +548,26 @@ function helpPanel(open) {
408
548
  ${q('D’on es gasta', `<p>nio no fa cap crida pel seu compte. Cada agent treballa a l’ordinador o al servidor amb el teu compte (Claude, Codex, Grok…). Ollama funciona al teu ordinador i no gasta res.</p>`)}
409
549
  </div></section>`;
410
550
  }
551
+ let pushOn = null; // null: encara no ho sé
552
+ async function refreshPush() {
553
+ let on = false;
554
+ try {
555
+ if ('serviceWorker' in navigator && 'PushManager' in window && Notification.permission === 'granted') {
556
+ const sub = await (await navigator.serviceWorker.ready).pushManager.getSubscription();
557
+ on = !!sub;
558
+ if (sub && state.connected) api('/api/push', { method: 'POST', body: sub.toJSON() }).catch(() => {});
559
+ }
560
+ } catch { /* sense avisos */ }
561
+ if (on !== pushOn) { pushOn = on; if (state.route.name === 'settings' || state.route.name === 'ajustos') render(); }
562
+ }
563
+ async function disablePush() {
564
+ try { const sub = await (await navigator.serviceWorker.ready).pushManager.getSubscription(); await sub?.unsubscribe(); } catch { /* res */ }
565
+ pushOn = false; toast('Avisos desactivats en aquest dispositiu'); render();
566
+ }
411
567
  function viewSettings() {
412
568
  const head = '<div class="top"><div><h1>Ajustos</h1><p>Avisos al mòbil i veu.</p></div></div>';
413
- const avisos = machine() ? `<section class="panel"><header><h2>Avisos al mòbil</h2></header><div class="body stack"><p class="small">Rep un avís quan un agent et demana permís o acaba la feina.</p>${isIOS && !standalone ? '<p class="muted small">A l’iPhone, primer afegeix nio a la pantalla d’inici (Comparteix → Afegeix a la pantalla d’inici) i obre’l des d’allà.</p>' : ''}<div class="row"><button class="btn ink grow" data-act="push" type="button">Activa els avisos</button><button class="btn" data-act="push-test" type="button">Envia’n un de prova</button></div></div></section>` : '';
569
+ const avisos = machine() ? `<section class="panel"><header><h2>Avisos al mòbil</h2></header><div class="body stack"><p class="small">Rep un avís quan un agent et demana permís o acaba la feina.</p>${isIOS && !standalone ? '<p class="muted small">A l’iPhone, primer afegeix nio a la pantalla d’inici (Comparteix → Afegeix a la pantalla d’inici) i obre’l des d’allà.</p>' : ''}${pushOn ? '<p class="small"><span class="chip ok">' + svg(ICON.check) + 'Avisos activats en aquest dispositiu</span></p>' : ''}<div class="row">${pushOn ? '<button class="btn ink grow" data-act="push-test" type="button">Envia’n un de prova</button><button class="btn" data-act="push-off" type="button">Desactiva</button>' : '<button class="btn ink grow" data-act="push" type="button">Activa els avisos</button>'}</div></div></section>` : '';
570
+ if (machine() && pushOn === null) refreshPush();
414
571
  return `${head}<div class="stack-lg" style="max-width:640px">${avisos}${voicePanel()}</div>`;
415
572
  }
416
573
  function viewHelp() {
@@ -430,8 +587,11 @@ function viewComputers() {
430
587
  <div class="stack">
431
588
  <section class="panel"><header><h2>Agents</h2><button class="btn sm ghost" data-act="refresh-agents" type="button">${svg(ICON.refresh)}Torna a detectar</button></header><div class="body">${info ? info.agents.map((a) => `<div class="agent-row"><i class="dot ${a.available ? 'on' : ''}"></i><div class="grow"><div class="row"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="muted small mono">${esc(a.available ? a.version || 'Disponible' : a.login)}</div></div></div>`).join('') : '<p class="muted">Carregant…</p>'}</div></section>
432
589
  ${info ? `<section class="panel"><header><h2>Sistema</h2></header><div class="body"><dl class="kv"><dt>Sistema</dt><dd>${esc(info.platform)} · ${esc(info.user)}</dd><dt>Versió de nio</dt><dd>${esc(info.version)}</dd></dl></div></section>` : ''}
590
+ ${info?.device ? `<section class="panel"><header><h2>Dispositius amb accés</h2></header><div class="body stack" id="devList">${devicesHtml(devCache.get(m.url))}</div></section>` : ''}
433
591
  </div>
434
592
  </div>` : '';
593
+ if (detail && info?.device) loadDevices();
594
+ if (ms.length > 1) probeAll();
435
595
  return `${head}<div class="computers">${ms.map(computerCard).join('')}</div>${detail}`;
436
596
  }
437
597
 
@@ -534,7 +694,7 @@ function addComputerSheet(tab, opts = {}) {
534
694
  if (e.target.closest('[data-close]')) { stop(); return closeSheet(); }
535
695
  if (e.target.closest('[data-scan]')) {
536
696
  const v = $('#scanVideo', sh); v.hidden = false;
537
- try { scan = await startScan(v, (text) => { const pair = pairFrom(text); if (pair) { stop(); closeSheet(); addMachine(pair); toast('Connectant…'); go('#/sessions'); } }); }
697
+ try { scan = await startScan(v, (text) => { const pair = pairFrom(text); if (pair) { stop(); closeSheet(); toast('Connectant…'); pairAndAdd(pair); go('#/sessions'); } }); }
538
698
  catch { v.hidden = true; toast('No puc obrir la càmera. Escriu el codi.'); }
539
699
  }
540
700
  };
@@ -556,7 +716,7 @@ function renameSheet(i) {
556
716
  const m = store.machines[i]; if (!m) return;
557
717
  openSheet(`<h2>Canvia el nom</h2><p class="muted small">Només el veus tu, en aquest dispositiu. L’ordinador es continua dient «${esc(m.name)}» a la resta.</p>
558
718
  <form class="field-row" id="renameForm"><input type="text" id="renameIn" value="${esc(m.name)}" maxlength="40" autocomplete="off" spellcheck="false" required aria-label="Nom de l’ordinador"><button class="btn ink" type="submit">Desa</button></form>`, (sh) => {
559
- const inp = $('#renameIn', sh); setTimeout(() => { inp.focus(); inp.select(); }, 60);
719
+ const inp = $('#renameIn', sh); if (!isTouch) setTimeout(() => { inp.focus(); inp.select(); }, 60);
560
720
  $('#renameForm', sh).onsubmit = (e) => {
561
721
  e.preventDefault(); e.stopPropagation();
562
722
  const v = inp.value.trim(); if (!v) return;
@@ -577,25 +737,236 @@ function stateChip(s) {
577
737
  if (s.state === 'error') return '<span class="chip error">Error</span>';
578
738
  return '';
579
739
  }
740
+ let manage = null; // Set d'ids seleccionats quan el mode «Gestiona» és actiu
741
+ const DAY = 864e5, money = (n) => (n ? `${n < 0.01 ? '<0,01' : n.toFixed(2).replace('.', ',')} $` : '0 $');
742
+ const manageBar = (list) => {
743
+ const n = manage.size, cost = list.filter((s) => manage.has(s.id)).reduce((t, s) => t + (s.cost || 0), 0);
744
+ return `<div class="manage-bar"><div class="row"><span class="muted small">Selecciona</span><button class="chip" type="button" data-man="all">Totes</button><button class="chip" type="button" data-man="idle">Acabades</button><button class="chip" type="button" data-man="old">Més de 30 dies</button><button class="chip" type="button" data-man="err">Amb error</button><button class="chip" type="button" data-man="none">Cap</button></div>
745
+ <div class="row between"><span class="muted small">${n ? `${n} seleccionades · ${money(cost)} gastats` : `${list.length} sessions · ${money(list.reduce((t, s) => t + (s.cost || 0), 0))} en total`}</span><span class="row"><button class="btn ghost" type="button" data-act="manage-off">Fet</button><button class="btn danger" type="button" data-act="manage-del" ${n ? '' : 'disabled'}>Esborra${n ? ` (${n})` : ''}</button></span></div></div>`;
746
+ };
747
+ function manageSelect(kind) {
748
+ const list = [...state.sessions.values()], now = Date.now();
749
+ const f = { all: () => true, none: () => false, idle: (x) => x.state === 'idle' && !x.pending, old: (x) => now - x.updated > 30 * DAY && x.state !== 'running', err: (x) => x.state === 'error' }[kind];
750
+ manage = new Set(list.filter(f).map((x) => x.id)); render();
751
+ }
752
+ async function manageDelete() {
753
+ const ids = [...manage]; if (!ids.length) return;
754
+ const run = ids.filter((id) => ['running', 'waiting'].includes(state.sessions.get(id)?.state)).length;
755
+ if (!confirm(`S’esborraran ${ids.length} converses${run ? ` (${run} encara treballen i s’aturaran)` : ''}. Els fitxers dels projectes no es toquen.`)) return;
756
+ let fail = 0;
757
+ await Promise.all(ids.map((id) => api(`/api/sessions/${id}`, { method: 'DELETE' }).then(() => handle({ type: 'removed', session: id })).catch(() => { fail++; })));
758
+ manage = fail ? new Set(ids.filter((id) => state.sessions.has(id))) : null;
759
+ toast(fail ? `No he pogut esborrar ${fail} sessions` : `${ids.length} sessions esborrades`); render();
760
+ }
580
761
  function viewSessions() {
581
762
  const list = [...state.sessions.values()].sort((a, b) => b.updated - a.updated);
582
763
  const many = store.machines.length > 1, mach = machine();
583
764
  const switcher = many ? `<button class="chip switch-chip" type="button" data-act="machines"><i class="dot ${state.connected ? 'on' : ''}"></i>${esc(mach.name)}${svg(ICON.chev)}</button>` : '';
584
765
  const hint = mobileHint();
585
- const head = `<div class="top"><div><h1>Sessions</h1><p>Cada sessió és una conversa amb un agent, dins d’una carpeta.</p>${switcher}</div><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>${hint}`;
766
+ const head = `<div class="top"><div><h1>Sessions</h1><p>Cada sessió és una conversa amb un agent, dins d’una carpeta.</p>${switcher}</div><span class="row">${list.length && state.connected && !manage ? '<button class="btn" data-act="manage-on" type="button">Gestiona</button>' : ''}${manage ? '' : `<button class="btn ink new-fab" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button>`}</span></div>${hint}${manage && list.length ? manageBar(list) : ''}`;
767
+ if (machine() && !state.synced && !state.lastError && !list.length) return `${head}<div class="list" aria-busy="true">${[0, 1, 2].map(() => '<div class="card skel-card"><i class="skel" style="width:34%"></i><i class="skel tall" style="width:78%"></i><i class="skel" style="width:56%"></i></div>').join('')}</div>`;
586
768
  if (!state.connected && !list.length) {
587
769
  return `${head}<div class="panel empty">${bot(state.lastError ? 'sleep' : 'work')}<h2>${esc(state.lastError || 'Connectant amb l’ordinador…')}</h2>${state.lastError ? `<p>${mach.kind === 'vps' ? 'Potser el servidor s’ha reiniciat i l’adreça ha canviat.' : 'Comprova que nio continua engegat a l’ordinador i que el túnel està obert.'}</p><div class="row" style="justify-content:center"><button class="btn" data-act="retry" type="button">${svg(ICON.refresh)}Torna-ho a provar</button>${mach.kind === 'vps' && mach.via ? `<button class="btn ink" data-reconnect="${store.active}" type="button">Torna a connectar</button>` : ''}<a class="btn ghost" href="#/ordinadors">Ordinadors</a></div>` : ''}</div>`;
588
770
  }
771
+ if (manage) for (const id of [...manage]) if (!state.sessions.has(id)) manage.delete(id);
589
772
  return `${head}${list.length ? `<div class="list">${list.map((s) => `
590
- <a class="card session-card" href="#/s/${esc(s.id)}">
773
+ <${manage ? 'div role="checkbox" tabindex="0" aria-checked="' + manage.has(s.id) + '" data-pick="' + esc(s.id) + '"' : 'a href="#/s/' + esc(s.id) + '"'} class="card session-card ${manage ? 'pick-mode' : ''} ${manage?.has(s.id) ? 'picked' : ''}">
591
774
  <div class="row between"><span class="agent-tag nosq" data-agent="${esc(s.agent)}">${agentBot(s.agent, sessionMood(s))}${esc(AGENT_NAMES[s.agent] || s.agent)}</span>${stateChip(s)}</div>
592
775
  <div class="title">${esc(s.title || 'Sessió nova')}</div>
593
776
  ${s.state === 'error' && s.error ? `<div class="last err">${esc(s.error)}</div>` : s.last ? `<div class="last">${esc(s.last)}</div>` : ''}
594
- <div class="meta"><span class="mono">${esc(basename(s.cwd))}</span><span>·</span><span>${esc(MODES[s.mode]?.label || '')}</span><span>·</span><span>${ago(s.updated)}</span></div>
595
- </a>`).join('')}</div>` : `<div class="panel empty">${bot('idle')}<h2>Encara no hi ha cap sessió</h2><p>Tria un agent i una carpeta, i explica-li què ha de fer.</p><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>`}`;
777
+ <div class="meta"><span class="mono">${esc(basename(s.cwd))}</span><span>·</span><span>${esc(MODES[s.mode]?.label || '')}</span><span>·</span><span>${ago(s.updated)}</span>${s.cost ? `<span>·</span><span>${money(s.cost)}</span>` : ''}</div>
778
+ </${manage ? 'div' : 'a'}>`).join('')}</div>` : `<div class="panel empty">${bot('idle')}<h2>Encara no hi ha cap sessió</h2><p>Tria un agent i una carpeta, i explica-li què ha de fer.</p><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>`}`;
596
779
  }
597
780
 
598
781
  /* ---------- Aprovacions */
782
+ /* ---------- Dispositius amb accés */
783
+ const devCache = new Map(); // adreça de l'ordinador -> llista
784
+ let devLoadAt = 0;
785
+ function devicesHtml(devs) {
786
+ if (!devs) return '<p class="muted small">Carregant…</p>';
787
+ return devs.map((d) => `<div class="agent-row"><span class="grow"><div class="row"><b>${esc(d.name)}</b>${d.current ? '<span class="chip">aquest</span>' : ''}</div><div class="muted small">${d.primary ? 'La fa servir el panell que obre nio i «nio ssh». Es canvia amb nio reset-token.' : `${d.lastSeen ? `Vist ${ago(d.lastSeen)}` : 'Encara no s’ha connectat'} · aparellat ${new Date(d.created).toLocaleDateString('ca')}`}</div></span>${d.primary ? '' : `<button class="btn sm danger" data-act="revoke" data-dev="${esc(d.id)}" type="button">${d.current ? 'Desconnecta' : 'Revoca'}</button>`}</div>`).join('') + '<p class="muted small">Revocar un dispositiu li treu l’accés a l’instant. Cada mòbil que s’aparella amb un codi en té un de propi.</p>';
788
+ }
789
+ async function loadDevices(force) {
790
+ const m = machine(); if (!m || (!force && Date.now() - devLoadAt < 10000)) return;
791
+ devLoadAt = Date.now();
792
+ try { devCache.set(m.url, await api('/api/devices')); } catch { devCache.set(m.url, []); }
793
+ const box = $('#devList'); if (box) box.innerHTML = devicesHtml(devCache.get(m.url));
794
+ }
795
+ async function revokeDevice(id) {
796
+ const m = machine(), d = devCache.get(m?.url)?.find((x) => x.id === id);
797
+ if (!m || !confirm(d?.current ? 'Aquest dispositiu deixarà de tenir accés a aquest ordinador.' : `«${d?.name || 'Aquest dispositiu'}» deixarà de tenir accés a aquest ordinador a l’instant.`)) return;
798
+ try {
799
+ await api(`/api/devices/${id}`, { method: 'DELETE' }); toast('Accés revocat');
800
+ if (d?.current) return forgetMachine(store.active);
801
+ loadDevices(true);
802
+ } catch (e) { toast(e.message); }
803
+ }
804
+
805
+ /* ---------- Activitat: registre d'accions de l'ordinador (què, per què, quan i qui) */
806
+ let activity = null, actFilter = 'tot';
807
+ const ACT_FILTERS = { tot: 'Tot', approval: 'Aprovacions', tool: 'Eines', job: 'Feines', device: 'Dispositius' };
808
+ function viewActivity() {
809
+ return `<div class="top"><div><h1>Activitat</h1><p>Tot el que han fet els agents en aquest ordinador: què, per què, quan i qui ho ha aprovat. Es guarda només a l’ordinador.</p></div><button class="btn" data-act="activity-refresh" type="button">${svg(ICON.refresh)}Actualitza</button></div>
810
+ <div class="seg act-filter" role="group">${Object.entries(ACT_FILTERS).map(([k, l]) => `<button type="button" data-filter="${k}" aria-pressed="${k === actFilter}">${l}</button>`).join('')}</div>
811
+ <div id="actList">${activityHtml()}</div>`;
812
+ }
813
+ function activityHtml() {
814
+ if (!activity) return `<div class="panel empty">${bot('work')}<h2>Carregant…</h2></div>`;
815
+ const list = activity.filter((e) => actFilter === 'tot' || e.type === actFilter);
816
+ if (!list.length) return `<div class="panel empty">${bot('idle')}<h2>Encara no hi ha res</h2><p>Quan els agents treballin, aquí en veuràs cada pas.</p></div>`;
817
+ const VERD = { allowed: '<span class="verdict allowed">✓ Aprovat</span>', denied: '<span class="verdict denied">✕ Rebutjat</span>' };
818
+ let day = '';
819
+ return `<div class="act">${list.map((e) => {
820
+ const d = new Date(e.t).toLocaleDateString('ca', { weekday: 'long', day: 'numeric', month: 'long' });
821
+ const head = d !== day ? `<div class="act-day">${esc((day = d))}</div>` : '';
822
+ const when = new Date(e.t).toLocaleTimeString('ca', { hour: '2-digit', minute: '2-digit' });
823
+ const who = e.type === 'approval' ? `${VERD[e.decision] || ''} <span class="muted small">per ${esc(e.by || '—')}</span>` : e.type === 'tool' ? `<span class="muted small">${esc(MODES[e.mode]?.label || e.mode || '')}</span>` : e.by ? `<span class="muted small">${esc(e.by)}</span>` : '';
824
+ return `${head}<div class="act-row act-${esc(e.type)}"><span class="act-t mono">${when}</span><div class="grow"><div class="row">${e.agent ? `<span class="agent-tag" data-agent="${esc(e.agent)}">${esc(AGENT_NAMES[e.agent] || e.agent)}</span>` : ''}${e.tool ? `<b>${esc(e.tool)}</b>` : ''}${who}</div><div class="mono small act-what">${esc(e.what || '')}</div>${e.why ? `<div class="muted small">Motiu: ${esc(e.why)}</div>` : ''}${e.session ? `<a class="muted small" href="#/s/${esc(e.session)}">${esc(e.title || 'Obre la sessió')}</a>` : ''}</div></div>`;
825
+ }).join('')}</div>`;
826
+ }
827
+ async function loadActivity() {
828
+ try { activity = await api('/api/audit?limit=300'); } catch (e) { activity = []; toast(e.message); }
829
+ const box = $('#actList'); if (box) box.innerHTML = activityHtml();
830
+ }
831
+
832
+ /* ---------- Memòria compartida (fitxers markdown a l'ordinador, per a Claude Code i Codex) */
833
+ let mem = null;
834
+ function viewMemory() {
835
+ return `<div class="top"><div><h1>Memòria</h1><p>El que recorden els teus agents en aquest ordinador. Claude Code i Codex la llegeixen i hi escriuen (servidor MCP de nio). Són fitxers markdown a <span class="mono">~/.nio/memory</span>.</p></div><button class="btn ink" data-act="mem-new" type="button">${svg(ICON.plus)}Fitxer nou</button></div>
836
+ <form class="row mem-search" id="memSearch" style="flex-wrap:nowrap"><input type="search" id="memQ" placeholder="Busca a la memòria…" aria-label="Busca a la memòria"><button class="btn" type="submit">Busca</button></form>
837
+ <div id="memBody">${memHtml()}</div>`;
838
+ }
839
+ function memHtml() {
840
+ if (!mem) return `<div class="panel empty">${bot('work')}<h2>Carregant…</h2></div>`;
841
+ if (mem.error) return `<div class="panel empty">${bot('sleep')}<h2>${esc(mem.error)}</h2><p>Cal nio 0.6 o més nou en aquest ordinador.</p></div>`;
842
+ if (mem.hits) return `<div class="list">${mem.hits.length ? mem.hits.map((h) => `<button class="card" type="button" data-mem="${esc(h.path)}"><b class="mono small">${esc(h.path)}:${h.line}</b><div class="muted small">${esc(h.text)}</div></button>`).join('') : '<p class="muted">Cap resultat.</p>'}<button class="btn ghost" data-act="mem-all" type="button">Mostra tots els fitxers</button></div>`;
843
+ const groups = {};
844
+ for (const f of mem.files) (groups[f.path.includes('/') ? f.path.split('/')[0] : ''] ||= []).push(f);
845
+ return Object.entries(groups).map(([g, fs]) => `${g ? `<div class="section-title">${esc(g)}</div>` : ''}<div class="list">${fs.map((f) => `<button class="card row" type="button" data-mem="${esc(f.path)}" style="flex-wrap:nowrap"><span class="grow"><b>${esc(f.path.split('/').pop().replace(/\.md$/, ''))}</b><div class="muted small">${ago(f.updated)} · ${bytes(f.size)}</div></span></button>`).join('')}</div>`).join('') || '<p class="muted">La memòria és buida.</p>';
846
+ }
847
+ async function loadMemory() {
848
+ try { mem = await api('/api/memory'); } catch (e) { mem = { error: e.message }; }
849
+ const box = $('#memBody'); if (box) box.innerHTML = memHtml();
850
+ }
851
+ async function memEdit(p) {
852
+ let f = { path: p || 'projectes/', content: '' };
853
+ if (p) { try { f = await api(`/api/memory/f/${encodeURIComponent(p)}`); } catch (e) { return toast(e.message); } }
854
+ openSheet(`<h2>${p ? esc(p) : 'Fitxer nou'}</h2>
855
+ ${p ? '' : '<label class="f"><span>Nom (acaba en .md)</span><input type="text" id="memPath" value="projectes/" autocapitalize="off" spellcheck="false"></label>'}
856
+ <label class="f"><span>Contingut</span><textarea class="field mono" id="memText" rows="14" spellcheck="false">${esc(f.content)}</textarea></label>
857
+ <div class="row between sheet-foot">${p ? '<button class="btn danger" type="button" id="memDel">Esborra</button>' : '<span></span>'}<button class="btn ink" type="button" id="memSave">Desa</button></div>`, (sh) => {
858
+ $('#memSave', sh).onclick = async () => {
859
+ const path = p || $('#memPath', sh).value.trim();
860
+ try { await api(`/api/memory/f/${encodeURIComponent(path)}`, { method: 'PUT', body: { content: $('#memText', sh).value } }); toast('Desat'); closeSheet(); mem = null; loadMemory(); }
861
+ catch (e) { toast(e.message, 4200); }
862
+ };
863
+ if (p) $('#memDel', sh).onclick = async () => {
864
+ if (!confirm(`S’esborrarà «${p}» de la memòria. Els agents ja no ho recordaran.`)) return;
865
+ try { await api(`/api/memory/f/${encodeURIComponent(p)}`, { method: 'DELETE' }); toast('Esborrat'); closeSheet(); loadMemory(); } catch (e) { toast(e.message); }
866
+ };
867
+ });
868
+ }
869
+ document.addEventListener('click', (e) => { const b = e.target.closest('[data-mem]'); if (b) memEdit(b.dataset.mem); });
870
+ document.addEventListener('submit', async (e) => {
871
+ if (e.target.id !== 'memSearch') return;
872
+ e.preventDefault(); const q = $('#memQ').value.trim(); if (!q) return loadMemory();
873
+ try { mem = { ...(mem || {}), hits: await api(`/api/memory/search?q=${encodeURIComponent(q)}`) }; } catch (err) { toast(err.message); }
874
+ $('#memBody').innerHTML = memHtml();
875
+ });
876
+
877
+ /* ---------- Feines: cua, cadenes i programació (l'orquestrador viu a l'ordinador) */
878
+ let jobsData = null;
879
+ const JOB_CHIP = { pendent: '', 'en curs': 'running', feta: 'ok', fallada: 'error', 'cancel·lada': '', programada: 'waiting' };
880
+ const CRON_PRESETS = [['', 'Només una vegada'], ['0 9 * * *', 'Cada dia a les 9'], ['0 9 * * 1-5', 'Dies feiners a les 9'], ['0 * * * *', 'Cada hora'], ['0 8 * * 1', 'Cada dilluns a les 8'], ['custom', 'Personalitzada (cron)…']];
881
+ function jobWhen(j) {
882
+ if (j.state === 'programada') return `${CRON_PRESETS.find(([c]) => c === j.cron)?.[1] || `cron ${j.cron}`} · propera ${j.runAt ? new Date(j.runAt).toLocaleString('ca', { weekday: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'}`;
883
+ if (j.state === 'pendent' && j.runAt && j.runAt > Date.now()) return `Començarà ${new Date(j.runAt).toLocaleString('ca', { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}`;
884
+ if (j.state === 'pendent' && j.after) return `Quan acabi «${jobsData?.jobs.find((x) => x.id === j.after)?.title || 'la feina anterior'}»`;
885
+ if (j.finished) return ago(j.finished);
886
+ return j.started ? `Des de ${ago(j.started)}` : 'A la cua';
887
+ }
888
+ function jobCard(j) {
889
+ const live = j.state === 'en curs' || j.state === 'pendent' || j.state === 'programada';
890
+ return `<div class="card job" data-job="${esc(j.id)}">
891
+ <div class="row between"><span class="row"><span class="agent-tag" data-agent="${esc(j.agent)}">${esc(AGENT_NAMES[j.agent] || j.agent)}</span>${j.after ? '<span class="chip">encadenada</span>' : ''}${j.rule ? `<span class="chip" title="Regla d’encaminament">regla</span>` : ''}</span><span class="chip ${JOB_CHIP[j.state] || ''}">${j.state === 'en curs' ? '<i class="pulse"></i>' : ''}${esc(j.state)}</span></div>
892
+ <div class="title">${esc(j.title)}</div>
893
+ <div class="meta"><span class="mono">${esc(basename(j.cwd))}</span><span>·</span><span>${esc(MODES[j.mode]?.label || '')}</span><span>·</span><span>${esc(jobWhen(j))}</span></div>
894
+ ${j.error && j.state !== 'feta' ? `<div class="muted small" style="margin-top:6px">${esc(j.error)}</div>` : ''}
895
+ <div class="row" style="margin-top:10px">${j.session ? `<a class="btn sm" href="#/s/${esc(j.session)}">Obre la sessió</a>` : ''}${j.state === 'pendent' || j.state === 'programada' ? `<button class="btn sm" data-job-act="run" type="button">${j.state === 'programada' ? 'Executa ara' : 'Comença ja'}</button>` : ''}${live ? `<button class="btn sm ghost" data-job-act="cancel" type="button">${j.state === 'programada' ? 'Atura la programació' : 'Cancel·la'}</button>` : `<button class="btn sm ghost" data-job-act="del" type="button">Treu de la llista</button>`}</div>
896
+ </div>`;
897
+ }
898
+ function viewJobs() {
899
+ const head = `<div class="top"><div><h1>Feines</h1><p>Encarrega feines que es fan soles: ara, més tard o cada dia, i encadena-les («quan Claude Code acabi, que Codex ho revisi»). Les fan Claude Code o Codex a l’ordinador.</p></div><button class="btn ink" data-act="job-new" type="button">${svg(ICON.plus)}Nova feina</button></div>`;
900
+ if (!jobsData) return `${head}<div id="jobsBody"><div class="panel empty">${bot('work')}<h2>Carregant…</h2></div></div>`;
901
+ return `${head}<div id="jobsBody">${jobsHtml()}</div>`;
902
+ }
903
+ function jobsHtml() {
904
+ if (jobsData.error) return `<div class="panel empty">${bot('sleep')}<h2>${esc(jobsData.error)}</h2><p>Cal nio 0.6 o més nou en aquest ordinador.</p></div>`;
905
+ const js = jobsData.jobs, sched = js.filter((j) => j.state === 'programada'), act = js.filter((j) => j.state === 'en curs' || j.state === 'pendent'), done = js.filter((j) => !['programada', 'en curs', 'pendent'].includes(j.state)).slice(0, 30);
906
+ const sec = (t, l) => (l.length ? `<div class="section-title">${t}</div><div class="list">${l.map(jobCard).join('')}</div>` : '');
907
+ return (sec('En marxa i a la cua', act) + sec('Programades', sched) + sec('Acabades', done)) || `<div class="panel empty">${bot('idle')}<h2>Encara no hi ha cap feina</h2><p>Per exemple: «Cada dia feiner a les 9, revisa les issues noves i fes-me’n un resum».</p><button class="btn ink" data-act="job-new" type="button">${svg(ICON.plus)}Nova feina</button></div>`;
908
+ }
909
+ async function loadJobs() {
910
+ try { jobsData = await api('/api/jobs'); } catch (e) { jobsData = { error: e.message, jobs: [] }; }
911
+ const box = $('#jobsBody'); if (box) box.innerHTML = jobsHtml();
912
+ }
913
+ function onJob(msg) {
914
+ if (!jobsData || jobsData.error) return;
915
+ if (msg.removed) jobsData.jobs = jobsData.jobs.filter((j) => j.id !== msg.removed);
916
+ else { const i = jobsData.jobs.findIndex((j) => j.id === msg.job.id); if (i >= 0) jobsData.jobs[i] = msg.job; else jobsData.jobs.unshift(msg.job); }
917
+ const box = $('#jobsBody'); if (box) box.innerHTML = jobsHtml();
918
+ }
919
+ async function newJobSheet() {
920
+ let projects = [];
921
+ try { projects = await api('/api/projects'); } catch (e) { return toast(e.message); }
922
+ const agents = (state.info?.agents || []).filter((a) => a.available && ['claude', 'codex'].includes(a.id));
923
+ const prev = (jobsData?.jobs || []).filter((j) => ['pendent', 'en curs', 'feta'].includes(j.state)).slice(0, 15);
924
+ const sel = { agent: 'auto', mode: 'preguntar', when: 'ara' };
925
+ openSheet(`<h2>Nova feina</h2>
926
+ <label class="f"><span>Què ha de fer?</span><textarea class="field" id="jobPrompt" rows="3" placeholder="Per exemple: revisa les issues noves i fes-me’n un resum"></textarea></label>
927
+ <div class="grid-2"><label class="f"><span>Agent</span><select id="jobAgent"><option value="auto">Automàtic (regles)</option>${agents.map((a) => `<option value="${a.id}">${esc(a.name)}</option>`).join('')}</select></label>
928
+ <label class="f"><span>Carpeta</span><select id="jobCwd">${projects.map((p) => `<option value="${esc(p.path)}">${esc(p.name)}</option>`).join('')}</select></label></div>
929
+ <div class="stack"><div class="section-title" style="margin:0">Permisos</div><div class="seg" id="jobMode" role="group">${Object.entries(MODES).map(([k, m]) => `<button type="button" data-jmode="${k}" aria-pressed="${k === sel.mode}">${m.label}</button>`).join('')}</div></div>
930
+ <div class="grid-2"><label class="f"><span>Quan</span><select id="jobWhen"><option value="ara">Ara</option><option value="mes">Més tard…</option>${prev.length ? '<option value="after">Quan acabi una altra feina…</option>' : ''}<option value="cron">Periòdicament…</option></select></label>
931
+ <div id="jobWhenX"></div></div>
932
+ ${agents.some((a) => a.id === 'codex') ? '<label class="row check"><input type="checkbox" id="jobReview"> <span>Quan acabi, que Codex revisi els canvis</span></label>' : ''}
933
+ <div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Cancel·la</button><button class="btn ink" id="jobGo" type="button">Posa-la a la cua</button></div>`, (sh) => {
934
+ const x = $('#jobWhenX', sh);
935
+ $('#jobWhen', sh).onchange = (e) => {
936
+ sel.when = e.target.value;
937
+ x.innerHTML = sel.when === 'mes' ? '<label class="f"><span>Dia i hora</span><input type="datetime-local" id="jobAt"></label>'
938
+ : sel.when === 'after' ? `<label class="f"><span>Després de</span><select id="jobAfter">${prev.map((j) => `<option value="${esc(j.id)}">${esc(j.title)} (${esc(j.state)})</option>`).join('')}</select></label>`
939
+ : sel.when === 'cron' ? `<label class="f"><span>Cada quan</span><select id="jobCron">${CRON_PRESETS.slice(1).map(([c, l]) => `<option value="${c}">${l}</option>`).join('')}</select></label><input type="text" id="jobCronX" placeholder="min hora dia mes dia-setmana" hidden autocapitalize="off" spellcheck="false" class="mono">` : '';
940
+ const c = $('#jobCron', sh); if (c) c.onchange = () => ($('#jobCronX', sh).hidden = c.value !== 'custom');
941
+ };
942
+ sh.addEventListener('click', (e) => { const m = e.target.closest('[data-jmode]'); if (m) { sel.mode = m.dataset.jmode; $$('[data-jmode]', sh).forEach((b) => b.setAttribute('aria-pressed', b.dataset.jmode === sel.mode)); } if (e.target.closest('[data-close]')) closeSheet(); });
943
+ $('#jobGo', sh).onclick = async () => {
944
+ const prompt = $('#jobPrompt', sh).value.trim(); if (!prompt) return toast('Explica què ha de fer la feina');
945
+ const body = { prompt, agent: $('#jobAgent', sh).value, cwd: $('#jobCwd', sh).value, mode: sel.mode };
946
+ if (sel.when === 'mes') { const v = $('#jobAt', sh)?.value; if (!v) return toast('Tria el dia i l’hora'); body.at = new Date(v).getTime(); }
947
+ if (sel.when === 'after') body.after = $('#jobAfter', sh).value;
948
+ if (sel.when === 'cron') body.cron = $('#jobCron', sh).value === 'custom' ? $('#jobCronX', sh).value.trim() : $('#jobCron', sh).value;
949
+ $('#jobGo', sh).disabled = true;
950
+ try {
951
+ const j = await api('/api/jobs', { method: 'POST', body });
952
+ if ($('#jobReview', sh)?.checked) await api('/api/jobs', { method: 'POST', body: { agent: 'codex', cwd: j.cwd, mode: 'llegir', after: j.id, title: `Revisió: ${j.title}`, prompt: 'Revisa els canvis que s’acaben de fer en aquesta carpeta (git diff i fitxers nous). Explica què està bé, què es pot millorar i si hi ha cap error. Context de la feina anterior:\n\n{{anterior}}' } });
953
+ closeSheet(); toast(body.cron ? 'Feina programada' : 'Feina a la cua'); loadJobs();
954
+ } catch (e) { toast(e.message, 4200); $('#jobGo', sh).disabled = false; }
955
+ };
956
+ if (!isTouch) setTimeout(() => $('#jobPrompt', sh)?.focus(), 80);
957
+ });
958
+ }
959
+ document.addEventListener('click', async (e) => {
960
+ const b = e.target.closest('[data-job-act]'); if (!b) return;
961
+ const id = b.closest('[data-job]').dataset.job, act = b.dataset.jobAct;
962
+ try {
963
+ if (act === 'run') await api(`/api/jobs/${id}/run`, { method: 'POST' });
964
+ if (act === 'cancel') await api(`/api/jobs/${id}/cancel`, { method: 'POST' });
965
+ if (act === 'del') await api(`/api/jobs/${id}`, { method: 'DELETE' });
966
+ loadJobs();
967
+ } catch (err) { toast(err.message); }
968
+ });
969
+
599
970
  function approvalCard(a, showSession = true) {
600
971
  const decided = a.state && a.state !== 'pending';
601
972
  if (decided && !showSession) {
@@ -607,13 +978,15 @@ function approvalCard(a, showSession = true) {
607
978
  ${showSession ? `<div class="row between"><span class="agent-tag" data-agent="${esc(a.agent)}">${esc(AGENT_NAMES[a.agent] || a.agent)}</span><a class="btn sm" href="#/s/${esc(a.session)}">Obre la sessió</a></div>${a.title ? `<div class="muted small">${esc(a.title)}</div>` : ''}` : ''}
608
979
  <div class="q">Vol fer servir <span class="mono">${esc(a.tool)}</span></div>
609
980
  <pre>${esc(prettyInput(a.input))}</pre>
981
+ ${a.reason ? `<div class="why"><span class="muted small">Motiu</span><p>${esc(a.reason)}</p></div>` : ''}
982
+ ${a.shot ? `<img class="shot" alt="Captura de la pantalla quan ho ha demanat" data-shot="${esc(a.shot)}">` : ''}
610
983
  ${decided ? `<div class="verdict ${esc(a.state)}">${a.state === 'allowed' ? '✓ Ho has aprovat' : '✕ Ho has rebutjat'}</div>` : '<div class="actions"><button class="btn danger" data-decide="deny" type="button">Rebutja</button><button class="btn ok" data-decide="allow" type="button">Aprova</button></div>'}
611
984
  </div>`;
612
985
  }
613
986
  function viewApprovals() {
614
987
  const list = [...state.approvals.values()].sort((a, b) => a.created - b.created);
615
988
  return `<div class="top"><div><h1>Aprovacions</h1><p>Quan un agent vol editar un fitxer o executar una ordre, t’ho demana aquí.</p></div></div>
616
- ${list.length ? `<div class="list">${list.map((a) => approvalCard(a)).join('')}</div>` : `<div class="panel empty">${bot('happy')}<h2>No hi ha res pendent</h2><p>Les sessions en mode «Demana permís» et faran les peticions aquí, i també t’avisaran al mòbil.</p></div>`}`;
989
+ ${machine() && !state.synced && !state.lastError && !list.length ? '<div class="list" aria-busy="true"><div class="card skel-card"><i class="skel" style="width:40%"></i><i class="skel tall" style="width:80%"></i><i class="skel" style="width:60%"></i></div></div>' : list.length ? `<div class="list">${list.map((a) => approvalCard(a)).join('')}</div>` : `<div class="panel empty">${bot('happy')}<h2>No hi ha res pendent</h2><p>Les sessions en mode «Demana permís» et faran les peticions aquí, i també t’avisaran al mòbil.</p></div>`}`;
617
990
  }
618
991
  // Marca una aprovació com a resolta a tot arreu: a la llista, a la conversa i a l'esdeveniment desat.
619
992
  function markDecided(id, st) {
@@ -663,10 +1036,10 @@ function renderSession() {
663
1036
  feedEls = new Map(); toolEls = new Map();
664
1037
  renderSessionHead();
665
1038
  const feed = $('#feed'), evs = state.events.get(id);
666
- if (!evs) feed.innerHTML = `<div class="ev-info">${machine() ? 'Carregant la conversa…' : 'Connecta un ordinador per veure aquesta sessió.'}</div>`;
1039
+ if (!evs) feed.innerHTML = machine() ? '<div class="skel-feed" aria-busy="true"><i class="skel r" style="width:46%"></i><i class="skel" style="width:88%;height:64px"></i><i class="skel" style="width:70%;height:40px"></i></div>' : `<div class="ev-info">${ 'Connecta un ordinador per veure aquesta sessió.'}</div>`;
667
1040
  else for (const e of [...evs.values()].sort((a, b) => a.seq - b.seq)) putEvent(e, false);
668
1041
  renderTyping(); renderComposer();
669
- requestAnimationFrame(() => (feed.scrollTop = feed.scrollHeight));
1042
+ requestAnimationFrame(() => toBottom(feed));
670
1043
  }
671
1044
  function sessionMood(s) { return !s ? 'idle' : s.state === 'running' ? 'work' : s.state === 'waiting' || s.pending ? 'peek' : s.state === 'error' ? 'sleep' : 'idle'; }
672
1045
  function renderSessionHead() {
@@ -679,10 +1052,10 @@ function renderSessionHead() {
679
1052
  if (!s) { bar.innerHTML = ''; return; }
680
1053
  const ag = state.info?.agents?.find((a) => a.id === s.agent);
681
1054
  bar.innerHTML = `<span class="chip"><span class="agent-tag nosq" data-agent="${esc(s.agent)}">${agentBot(s.agent, sessionMood(s))}${esc(AGENT_NAMES[s.agent] || s.agent)}</span></span>
682
- ${ag?.noModes ? '' : `<button class="chip" data-act="mode" type="button">${esc(MODES[s.mode]?.label || s.mode)}</button>`}
683
- <span class="chip mono">${esc(basename(s.cwd))}</span>
684
- <button class="chip mono" data-act="model" type="button" title="Canvia de model">${esc(s.model || 'Model per defecte')}</button>
685
- ${s.cost ? `<span class="chip mono">${s.cost.toFixed(2)} $</span>` : ''}
1055
+ ${ag?.noModes ? '' : `<button class="chip" data-act="mode" type="button">${svg(CHIP_ICON.mode)}<span>${esc(MODES[s.mode]?.label || s.mode)}</span></button>`}
1056
+ <button class="chip mono" data-act="folder" type="button" title="${esc(s.cwd)} · canvia de carpeta">${svg(CHIP_ICON.folder)}<span>${esc(basename(s.cwd))}</span></button>
1057
+ <button class="chip mono" data-act="model" type="button" title="Canvia de model">${svg(CHIP_ICON.model)}<span>${esc(s.model || 'Model per defecte')}</span></button>
1058
+ ${s.cost ? `<span class="chip mono">${svg(CHIP_ICON.cost)}<span>${s.cost.toFixed(2)} $</span></span>` : ''}
686
1059
  ${stateChip(s)}`;
687
1060
  }
688
1061
  // Alçada del camp de text segons el contingut, sense barra de desplaçament fins al límit.
@@ -692,7 +1065,10 @@ function autogrow(ta, max = 180) {
692
1065
  ta.style.height = `${Math.min(h, max)}px`;
693
1066
  ta.style.overflowY = h > max ? 'auto' : 'hidden';
694
1067
  }
695
- const nearBottom = (f) => f.scrollHeight - f.scrollTop - f.clientHeight < 140;
1068
+ // Al mòbil fa scroll el document sencer; a l'escriptori, la conversa té el seu propi espai.
1069
+ const scroller = (f) => (f && /(auto|scroll)/.test(getComputedStyle(f).overflowY) ? f : document.scrollingElement);
1070
+ const nearBottom = (f) => { const s = scroller(f), h = s === document.scrollingElement ? innerHeight : s.clientHeight; return s.scrollHeight - s.scrollTop - h < 140; };
1071
+ const toBottom = (f) => { const s = scroller(f); s.scrollTop = s.scrollHeight; };
696
1072
  function putEvent(e, scroll = true) {
697
1073
  const feed = $('#feed'); if (!feed) return;
698
1074
  const stick = nearBottom(feed);
@@ -716,7 +1092,7 @@ function putEvent(e, scroll = true) {
716
1092
  feedEls.set(e.seq, node);
717
1093
  if (e.kind === 'tool') toolEls.set(e.id, node);
718
1094
  renderTyping();
719
- if (scroll && stick) feed.scrollTop = feed.scrollHeight;
1095
+ if (scroll && stick) toBottom(feed);
720
1096
  }
721
1097
  function eventNode(e) {
722
1098
  const s = state.sessions.get(state.route.id);
@@ -757,6 +1133,7 @@ const NIO_CMDS = [
757
1133
  { name: 'model', hint: 'Canvia de model' },
758
1134
  { name: 'stop', hint: 'Atura l’agent' },
759
1135
  { name: 'nova', hint: 'Nova sessió amb el mateix agent i carpeta' },
1136
+ { name: 'carpeta', hint: 'Canvia la carpeta de la sessió' },
760
1137
  { name: 'git', hint: 'Git i GitHub: commit i PR' },
761
1138
  { name: 'nom', hint: 'Posa nom a la sessió', free: true },
762
1139
  { name: 'opcions', hint: 'Opcions de la sessió' },
@@ -781,12 +1158,47 @@ async function runCmd(s, name, arg) {
781
1158
  case 'model': return arg ? patch({ model: arg }, `Model: ${arg}`) : modelSheet(s);
782
1159
  case 'stop': try { await api(`/api/sessions/${s.id}/stop`, { method: 'POST' }); toast('Aturat'); } catch (e) { toast(e.message); } return;
783
1160
  case 'nova': return newSessionSheet({ agent: s.agent, cwd: s.cwd, mode: s.mode });
1161
+ case 'carpeta': return folderSheet(s);
784
1162
  case 'git': return gitSheet(s.id);
785
1163
  case 'nom': return arg ? patch({ title: arg }, 'Nom desat') : toast('Escriu el nom: /nom Arregla els tests');
786
1164
  case 'opcions': return sessionOptions(s.id);
787
1165
  case 'ajuda': return helpCmdsSheet(s);
788
1166
  }
789
1167
  }
1168
+ const folderKey = (k) => `nio.${k}.${machine()?.id || machine()?.url}`;
1169
+ const getFavs = () => LS.get(folderKey('favs'), []);
1170
+ function toggleFav(p) { const f = getFavs(), n = f.includes(p) ? f.filter((x) => x !== p) : [p, ...f]; LS.set(folderKey('favs'), n); return n.includes(p); }
1171
+ function rememberFolder(p) { const l = LS.get(folderKey('last'), {}); LS.set(folderKey('last'), { ...l, cwd: p, recent: [p, ...(l.recent || []).filter((x) => x !== p)].slice(0, 6) }); }
1172
+ // Última utilitzada, preferides i recents, sense repeticions.
1173
+ function quickFolders(extra = []) {
1174
+ const l = LS.get(folderKey('last'), {}), favs = getFavs(), seen = new Set(), out = [];
1175
+ const add = (path, tag) => { if (path && !seen.has(path)) { seen.add(path); out.push({ path, name: basename(path), tag }); } };
1176
+ add(l.cwd, 'last'); favs.forEach((p) => add(p, 'fav')); (l.recent || []).forEach((p) => add(p)); extra.forEach((p) => add(p.path));
1177
+ return out;
1178
+ }
1179
+ const folderChip = (f) => `<button type="button" class="chip ${f.tag === 'fav' ? 'fav' : ''}" data-fs="${esc(f.path)}" title="${esc(f.path)}">${f.tag === 'fav' ? '★ ' : f.tag === 'last' ? '↺ ' : ''}${esc(f.name)}</button>`;
1180
+ const favBtn = (p) => `<button class="chip" type="button" data-fav aria-pressed="${getFavs().includes(p)}">${getFavs().includes(p) ? '★ Preferida' : '☆ Desa com a preferida'}</button>`;
1181
+ function folderSheet(s) {
1182
+ let d = null;
1183
+ const draw = () => {
1184
+ const crumbs = d ? d.path.split(/[\\/]/).filter(Boolean) : [];
1185
+ $('#fsBody').innerHTML = !d ? '<p class="muted small">Carregant…</p>' : `<div class="row">${favBtn(d.path)}${quickFolders().slice(0, 8).map(folderChip).join('')}</div><div class="fs-bar"><button class="icon-btn" type="button" data-fs="${esc(d.parent || '')}" ${d.parent ? '' : 'disabled'} aria-label="Carpeta de dalt">${svg(ICON.back)}</button><button class="chip" type="button" data-fs="${esc(d.home)}">Inici</button><span class="fs-path mono small" title="${esc(d.path)}">${esc(crumbs.slice(-2).join(' / ') || '/')}</span></div>
1186
+ <div class="proj-list fs-list">${d.dirs.map((x) => `<button type="button" class="proj" data-fs="${esc(x.path)}"><span class="ic">›</span><span class="grow"><div ${x.hidden ? 'class="muted"' : ''}>${esc(x.name)}</div></span></button>`).join('') || '<p class="muted small">No hi ha subcarpetes. Pots triar aquesta.</p>'}</div>`;
1187
+ const ok = $('#fsOk'); if (ok) { ok.disabled = !d || d.path === s.cwd; ok.textContent = d && d.path === s.cwd ? 'Ja hi és' : 'Treballa aquí'; }
1188
+ };
1189
+ const go = async (p) => { try { d = await api(`/api/fs?path=${encodeURIComponent(p || '')}`); } catch (e) { toast(e.message, 4200); } draw(); };
1190
+ openSheet(`<h2>Canvia de carpeta</h2><p class="muted small">Ara: <span class="mono">${esc(s.cwd)}</span>. La conversa continua i l’agent la recorda.</p><div id="fsBody"></div><div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Cancel·la</button><button class="btn ink" id="fsOk" type="button" disabled>Treballa aquí</button></div>`, (sh) => {
1191
+ sh.onclick = async (e) => {
1192
+ if (e.target.closest('[data-close]')) return closeSheet();
1193
+ const f = e.target.closest('[data-fs]'); if (f && !f.disabled) return go(f.dataset.fs);
1194
+ if (e.target.closest('[data-fav]') && d) { toggleFav(d.path); return draw(); }
1195
+ if (e.target.closest('#fsOk') && d) {
1196
+ try { const sum = await api(`/api/sessions/${s.id}`, { method: 'PATCH', body: { cwd: d.path } }); state.sessions.set(s.id, sum); rememberFolder(sum.cwd); closeSheet(); renderSessionHead(); cmdCache.delete(s.id); toast(`Carpeta: ${basename(sum.cwd)}`); } catch (er) { toast(er.message, 4200); }
1197
+ }
1198
+ };
1199
+ draw(); go(s.cwd);
1200
+ });
1201
+ }
790
1202
  function helpCmdsSheet(s) {
791
1203
  const ac = agentCmds(s.id);
792
1204
  openSheet(`<h2>Comandes</h2><p class="muted small">Escriu <span class="mono">/</span> al missatge per veure-les.</p><div class="list">${NIO_CMDS.map((c) => `<div class="card"><b class="mono">/${c.name}</b>${c.args ? ' <span class="muted small mono">l · p · t</span>' : ''}<div class="muted small">${esc(c.hint)}</div></div>`).join('')}</div>${ac.length ? `<p class="muted small">De ${esc(AGENT_NAMES[s.agent] || s.agent)}: ${ac.length} comandes i skills. Se li envien a l’agent tal qual.</p>` : ''}<div class="row sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button></div>`, (sh) => { $('[data-close]', sh).onclick = closeSheet; });
@@ -798,7 +1210,7 @@ function slashItems(s, v) {
798
1210
  if (!m) return [];
799
1211
  const q = m[1].toLowerCase();
800
1212
  const nio = NIO_CMDS.map((c) => ({ ...c, tag: 'nio' }));
801
- const ag = agentCmds(s.id).map((c) => ({ name: c.name, hint: c.hint, tag: AGENT_NAMES[s.agent] || s.agent, agent: true }));
1213
+ const ag = agentCmds(s.id).map((c) => ({ name: c.name, hint: c.hint, tag: (AGENT_NAMES[s.agent] || s.agent).split(' ')[0], agent: true }));
802
1214
  const all = [...nio, ...ag], starts = all.filter((c) => c.name.toLowerCase().startsWith(q)), has = all.filter((c) => !c.name.toLowerCase().startsWith(q) && c.name.toLowerCase().includes(q));
803
1215
  return [...starts, ...has].slice(0, 40).map((c) => ({ ...c, label: `/${c.name}` }));
804
1216
  }
@@ -806,10 +1218,11 @@ function renderComposer() {
806
1218
  const box = $('#composer'); if (!box) return;
807
1219
  const s = state.sessions.get(state.route.id), busy = !!s && (s.state === 'running' || s.state === 'waiting');
808
1220
  const prev = $('textarea', box)?.value || '';
809
- if (box.dataset.busy === String(busy) && $('textarea', box)) return;
810
- box.dataset.busy = String(busy);
1221
+ const waiting = s?.state === 'waiting';
1222
+ if (box.dataset.busy === String(busy) + waiting && $('textarea', box)) return;
1223
+ box.dataset.busy = String(busy) + waiting;
811
1224
  if (s) loadCmds(s.id).then(() => { if (state.route.id === s.id) drawSlash(); });
812
- box.innerHTML = `<div class="slash" id="slash" role="listbox" hidden></div><div class="inner"><textarea rows="1" id="msg" placeholder="${busy ? 'L’agent està treballant…' : 'Escriu a l’agent… (/ per a comandes)'}" enterkeyhint="send" aria-label="Missatge" aria-controls="slash"></textarea><button class="mic" id="micBtn" type="button" data-mic aria-label="Dicta amb el micròfon">${svg(ICON.mic)}</button><button class="send ${busy ? 'stop' : ''}" id="sendBtn" type="button" aria-label="${busy ? 'Atura l’agent' : 'Envia'}">${svg(busy ? ICON.stop : ICON.send)}</button></div>`;
1225
+ box.innerHTML = `<div class="slash" id="slash" role="listbox" hidden></div><div class="inner"><textarea rows="1" id="msg" placeholder="${waiting ? 'Esperant que aprovis o rebutgis…' : busy ? 'L’agent està treballant…' : 'Escriu a l’agent…'}" enterkeyhint="send" aria-label="Missatge" aria-controls="slash"></textarea><button class="mic" id="micBtn" type="button" data-mic aria-label="Dicta amb el micròfon">${svg(ICON.mic)}</button><button class="send ${busy ? 'stop' : ''}" id="sendBtn" type="button" aria-label="${busy ? 'Atura l’agent' : 'Envia'}">${svg(busy ? ICON.stop : ICON.send)}</button></div>`;
813
1226
  const ta = $('#msg'); ta.value = prev;
814
1227
  const grow = () => autogrow(ta);
815
1228
  let items = [], sel = 0;
@@ -829,7 +1242,8 @@ function renderComposer() {
829
1242
  ta.value = ''; grow(); drawSlash(); runCmd(cur, c.name, '');
830
1243
  };
831
1244
  ta.addEventListener('input', () => { grow(); sel = 0; drawSlash(); });
832
- pop.addEventListener('pointerdown', (e) => { const b = e.target.closest('[data-i]'); if (b) { e.preventDefault(); pick(+b.dataset.i); } });
1245
+ pop.addEventListener('mousedown', (e) => e.preventDefault());
1246
+ pop.addEventListener('click', (e) => { const b = e.target.closest('[data-i]'); if (b) pick(+b.dataset.i); });
833
1247
  const send = async () => {
834
1248
  const text = ta.value.trim(); if (!text || !s) return;
835
1249
  const c = text.match(/^\/([\w:.-]+)(?:\s+([\s\S]*))?$/), nio = c && NIO_CMDS.find((x) => x.name === c[1].toLowerCase());
@@ -854,11 +1268,22 @@ function renderComposer() {
854
1268
  /* ---------- Fulls */
855
1269
  function openSheet(html, onMount, cls) {
856
1270
  const d = $('#sheet'); d.classList.toggle('wiz', !!cls?.startsWith('wiz')); d.classList.toggle('compact', cls === 'wiz compact'); const si = $('#sheetIn'); si.onclick = si.onsubmit = si.oninput = si.onchange = null; si.innerHTML = cls?.startsWith('wiz') ? '' : `<div class="grab"></div>${html}`;
857
- if (!d.open) d.showModal(); raiseToasts(); if (T.on) $('#tour').hidden = true;
1271
+ if (!d.open) d.showModal(); raiseToasts();
1272
+ if (isTouch) { if (document.activeElement?.matches?.('input, textarea, select')) document.activeElement.blur(); } else setTimeout(() => focusIn(d), 30); if (T.on) $('#tour').hidden = true;
858
1273
  onMount?.($('#sheetIn'));
859
1274
  }
860
- const closeSheet = () => { const d = $('#sheet'); if (d.open) d.close(); };
1275
+ const closeSheet = () => { const d = $('#sheet'); if (d.open) d.close(); setTimeout(focusMain, 0); };
1276
+ // A l'ordinador el cursor és sempre on s'escriu; al mòbil no, perquè no s'obri el teclat.
1277
+ function focusIn(root) { if (isTouch || !root) return; const t = root.querySelector('textarea:not([disabled]), input:not([type=hidden]):not([type=checkbox]):not([type=radio]):not([disabled])'); if (t && document.activeElement !== t) { t.focus({ preventScroll: true }); if (t.select && t.tagName === 'INPUT' && t.value && /^(text|search)$/.test(t.type)) { /* sense seleccionar: el cursor al final */ const n = t.value.length; try { t.setSelectionRange(n, n); } catch { /* res */ } } } }
1278
+ function focusMain() {
1279
+ if (isTouch || $('#sheet')?.open || $('#palette')?.open) return;
1280
+ const r = state.route.name;
1281
+ if (r === 'session') $('#msg')?.focus({ preventScroll: true });
1282
+ else if (r === 'mobil' && local.engine) $('#lmsg')?.focus({ preventScroll: true });
1283
+ else if (r === 'models') $('#pullIn')?.focus({ preventScroll: true });
1284
+ }
861
1285
  $('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet') closeSheet(); });
1286
+ $('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
862
1287
 
863
1288
  // Claude Code no deixa «Via lliure» quan nio corre com a root (típic en un VPS).
864
1289
  const modeBlocked = (agentId, mode) => agentId === 'claude' && mode === 'tot' && state.info?.user === 'root';
@@ -936,10 +1361,10 @@ async function newSessionSheet(preset = {}) {
936
1361
  () => `<p class="wiz-q">Quin agent vols fer servir?</p><div class="picker">${[...agents].sort((x, y) => (y.available ? 1 : 0) - (x.available ? 1 : 0)).map((a) => `<button type="button" class="pick ${a.available ? '' : 'off'}" data-agent-id="${esc(a.id)}" aria-pressed="${a.id === sel.agent}"><div class="row between"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="sub">${esc(a.available ? a.version || 'Disponible' : 'No instal·lat')}</div></button>`).join('')}</div>`,
937
1362
  () => {
938
1363
  const crumbs = fsd ? fsd.path.split(/[\\/]/).filter(Boolean) : [];
939
- const quick = [...recents.map((p) => ({ path: p, name: basename(p) })), ...projects.filter((p) => !recents.includes(p.path))].slice(0, 8).map((p) => `<button type="button" class="chip" data-fs="${esc(p.path)}">${esc(p.name)}</button>`).join('');
1364
+ const qf = quickFolders(projects), favN = getFavs().length, quick = qf.filter((f) => f.tag).concat(qf.filter((f) => !f.tag).slice(0, 6)).slice(0, favN + 8).map(folderChip).join('');
940
1365
  return `<p class="wiz-q">A quina carpeta ha de treballar?</p>
941
1366
  <form class="pair-form" id="fsForm"><input type="text" id="fsIn" value="${esc(fsd?.path || sel.cwd)}" placeholder="/ruta/exacta/de/la/carpeta" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Ruta de la carpeta"><button class="btn" type="submit">Obre</button></form>
942
- <div class="fs-pick"><span class="muted small">Carpeta triada</span><b class="mono small">${esc(fsd?.path || sel.cwd || '—')}</b>${fsd?.git ? '<span class="chip">git</span>' : ''}</div>
1367
+ <div class="fs-pick"><span class="muted small">Carpeta triada</span><b class="mono small">${esc(fsd?.path || sel.cwd || '—')}</b>${fsd?.git ? '<span class="chip">git</span>' : ''}${favBtn(fsd?.path || sel.cwd)}</div>
943
1368
  <div class="row"><button class="chip" type="button" data-gh>${gh ? 'Torna a les carpetes' : 'Clona de GitHub'}</button>${quick}</div>
944
1369
  ${gh ? (gh.msg ? `<p class="muted small">${gh.msg}</p>` : '') + (gh.repos ? `<input type="search" id="ghQ" value="${esc(gh.q)}" placeholder="Cerca entre els teus repositoris${gh.user ? ' (' + esc(gh.user) + ')' : ''}" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Cerca un repositori"><div class="proj-list fs-list" id="ghList">${ghList()}</div>` : '')
945
1370
  : !fsd ? '<p class="muted small">Carregant…</p>' : `<div class="fs-bar"><button class="icon-btn" type="button" data-fs="${esc(fsd.parent || '')}" ${fsd.parent ? '' : 'disabled'} aria-label="Carpeta de dalt">${svg(ICON.back)}</button><button class="chip" type="button" data-fs="${esc(fsd.home)}">Inici</button><span class="fs-path mono small" title="${esc(fsd.path)}">${esc(crumbs.slice(-2).join(' / ') || '/')}</span></div>
@@ -978,6 +1403,7 @@ async function newSessionSheet(preset = {}) {
978
1403
  const ag = t.closest('[data-agent-id]');
979
1404
  if (ag) { const a = agents.find((x) => x.id === ag.dataset.agentId); if (!a.available) return toast(`Primer instal·la’l a l’ordinador: ${a.login}`, 4200); if (a.id !== sel.agent) { sel.agent = a.id; sel.model = ''; models = null; } step = 1; return draw(); }
980
1405
  const fp = t.closest('[data-fs]'); if (fp && !fp.disabled) return fsGo(fp.dataset.fs);
1406
+ if (t.closest('[data-fav]')) { toggleFav(fsd?.path || sel.cwd); return body(); }
981
1407
  if (t.closest('[data-gh]')) { if (gh) { gh = null; return body(); } return ghStart(); }
982
1408
  const rp = t.closest('[data-repo]'); if (rp) return ghClone(rp.dataset.repo, rp);
983
1409
  const mo = t.closest('[data-mode]'); if (mo) { sel.mode = mo.dataset.mode; return body(); }
@@ -1174,6 +1600,7 @@ function onOllama(msg) {
1174
1600
 
1175
1601
  /* ---------- Models per a la pissarra */
1176
1602
  window.NioBrains = {
1603
+ refresh: () => brainsForBoard(true),
1177
1604
  list() {
1178
1605
  const out = [{ id: 'rules', label: 'Ordres ràpides' }];
1179
1606
  if (machine() && state.connected) (state.ollama?.models || []).forEach((m) => out.push({ id: 'ollama:' + m.name, label: `Ollama · ${m.name}` }));
@@ -1195,9 +1622,12 @@ window.NioBrains = {
1195
1622
  throw new Error('Aquest model ja no està disponible');
1196
1623
  }
1197
1624
  };
1198
- async function brainsForBoard() {
1625
+ let brainsAt = 0;
1626
+ async function brainsForBoard(force) {
1199
1627
  if (state.route.name !== 'pissarra') return;
1200
- if (machine() && state.connected && !state.ollama) { try { state.ollama = await api('/api/ollama'); } catch { /* sense Ollama */ } }
1628
+ window.NioBoard?.refreshBrains();
1629
+ if (!machine() || !state.connected || (!force && state.ollama && Date.now() - brainsAt < 30000)) return;
1630
+ try { const o = await api('/api/ollama'); state.ollama = o; brainsAt = Date.now(); } catch { /* sense Ollama */ }
1201
1631
  window.NioBoard?.refreshBrains();
1202
1632
  }
1203
1633
 
@@ -1210,7 +1640,7 @@ async function enablePush() {
1210
1640
  const reg = await navigator.serviceWorker.ready;
1211
1641
  const sub = (await reg.pushManager.getSubscription()) || (await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64uToU8(state.info.vapidKey) }));
1212
1642
  await api('/api/push', { method: 'POST', body: sub.toJSON() });
1213
- syncServiceWorker(); toast('Avisos activats');
1643
+ syncServiceWorker(); pushOn = true; toast('Avisos activats'); if (state.route.name === 'settings' || state.route.name === 'ajustos') render();
1214
1644
  } catch (e) { toast(e.message, 4200); }
1215
1645
  }
1216
1646
  async function syncServiceWorker() {
@@ -1250,7 +1680,7 @@ function viewLocal() {
1250
1680
  function bindLocal() {
1251
1681
  const feed = $('#lfeed'); if (!feed) return;
1252
1682
  for (const m of local.messages) feed.appendChild(m.role === 'user' ? el(`<div class="msg-user">${esc(m.content)}</div>`) : el(`<div class="msg-ai">${md(stripThink(m.content))}</div>`));
1253
- feed.scrollTop = feed.scrollHeight;
1683
+ toBottom(feed);
1254
1684
  const ta = $('#lmsg');
1255
1685
  if (local.draft) { ta.value = local.draft; local.draft = ''; autogrow(ta); }
1256
1686
  ta.addEventListener('input', () => autogrow(ta));
@@ -1300,7 +1730,7 @@ async function sendLocal() {
1300
1730
  const feed = $('#lfeed'); if (!local.messages.length) feed.innerHTML = '';
1301
1731
  local.messages.push({ role: 'user', content: text });
1302
1732
  feed.appendChild(el(`<div class="msg-user">${esc(text)}</div>`));
1303
- const out = el('<div class="msg-ai cursor"></div>'); feed.appendChild(out); feed.scrollTop = feed.scrollHeight;
1733
+ const out = el('<div class="msg-ai cursor"></div>'); feed.appendChild(out); toBottom(feed);
1304
1734
  local.busy = true; local.stopped = false; setLocalBusy(true); let acc = '';
1305
1735
  try {
1306
1736
  const chunks = await local.engine.chat.completions.create({
@@ -1309,7 +1739,7 @@ async function sendLocal() {
1309
1739
  extra_body: { enable_thinking: false }
1310
1740
  });
1311
1741
  for await (const c of chunks) {
1312
- acc += c.choices[0]?.delta?.content || ''; out.innerHTML = md(stripThink(acc)); if (nearBottom(feed)) feed.scrollTop = feed.scrollHeight;
1742
+ acc += c.choices[0]?.delta?.content || ''; out.innerHTML = md(stripThink(acc)); if (nearBottom(feed)) toBottom(feed);
1313
1743
  if (local.stopped) break;
1314
1744
  }
1315
1745
  } catch (e) {
@@ -1395,8 +1825,23 @@ function voiceSheet() {
1395
1825
  }
1396
1826
 
1397
1827
  /* ---------------------------------------------------------------- accions */
1828
+ document.addEventListener('click', (e) => {
1829
+ const f = e.target.closest('[data-filter]'); if (!f) return;
1830
+ actFilter = f.dataset.filter; $$('.act-filter button').forEach((b) => b.setAttribute('aria-pressed', b.dataset.filter === actFilter));
1831
+ const box = $('#actList'); if (box) box.innerHTML = activityHtml();
1832
+ });
1833
+ // Les captures de les aprovacions es demanen amb la clau: no poden anar en un <img src> directe.
1834
+ async function hydrateShots(root = document) {
1835
+ const m = machine(); if (!m) return;
1836
+ for (const img of $$('img[data-shot]:not([src])', root)) {
1837
+ try {
1838
+ const res = await fetch(`${m.url}/api/screen/shot/${encodeURIComponent(img.dataset.shot)}`, { headers: { Authorization: `Bearer ${m.token}` } });
1839
+ if (!res.ok) throw 0; img.src = URL.createObjectURL(await res.blob());
1840
+ } catch { img.remove(); }
1841
+ }
1842
+ }
1398
1843
  document.addEventListener('click', async (e) => {
1399
- const a = e.target.closest('[data-act], [data-speak], [data-mic], [data-copy], [data-decide], [data-machine], [data-forget], [data-rename], [data-reconnect], [data-lm]');
1844
+ const a = e.target.closest('[data-act], [data-speak], [data-mic], [data-copy], [data-decide], [data-machine], [data-forget], [data-rename], [data-reconnect], [data-lm], [data-man], [data-pick]');
1400
1845
  if (!a) return;
1401
1846
  if (a.dataset.copy) {
1402
1847
  try { await navigator.clipboard.writeText(a.dataset.copy); } catch { return toast(a.dataset.copy); }
@@ -1409,6 +1854,8 @@ document.addEventListener('click', async (e) => {
1409
1854
  }
1410
1855
  if (a.dataset.speak != null) return speak(a);
1411
1856
  if (a.dataset.mic != null) return toggleMic();
1857
+ if (a.dataset.man) return manageSelect(a.dataset.man);
1858
+ if (a.dataset.pick) { manage.has(a.dataset.pick) ? manage.delete(a.dataset.pick) : manage.add(a.dataset.pick); return render(); }
1412
1859
  if (a.dataset.decide) return decide(a.closest('[data-approval]'), a.dataset.decide);
1413
1860
  if (a.dataset.machine != null) return switchMachine(Number(a.dataset.machine));
1414
1861
  if (a.dataset.reconnect != null) { const m = store.machines[Number(a.dataset.reconnect)]; reconTry.delete(m.via); if (sshHelper()) { autoReconnect({ ...m }); return; } return addComputerSheet('vps', { target: m.via, auto: true }); }
@@ -1417,6 +1864,9 @@ document.addEventListener('click', async (e) => {
1417
1864
  if (a.dataset.lm) { if (local.loading) return; LS.set('nio.localModel', a.dataset.lm); return render(); }
1418
1865
  switch (a.dataset.act) {
1419
1866
  case 'new': return newSessionSheet();
1867
+ case 'manage-on': manage = new Set(); return render();
1868
+ case 'manage-off': manage = null; return render();
1869
+ case 'manage-del': return manageDelete();
1420
1870
  case 'ollama-refresh': state.ollama = null; return loadOllama(true);
1421
1871
  case 'ollama-pull': return pullModel(a.dataset.name || $('#pullIn')?.value);
1422
1872
  case 'ollama-cancel': try { await api(`/api/ollama/pull/${encodeURIComponent(a.dataset.name)}`, { method: 'DELETE' }); } catch (err) { toast(err.message); } return;
@@ -1428,11 +1878,18 @@ document.addEventListener('click', async (e) => {
1428
1878
  case 'session-more': return sessionOptions(state.route.id);
1429
1879
  case 'refresh-agents': try { state.info = await api('/api/info?refresh=1'); render(); toast('Agents actualitzats'); } catch (err) { toast(err.message); } return;
1430
1880
  case 'push': return enablePush();
1881
+ case 'push-off': return disablePush();
1882
+ case 'revoke': return revokeDevice(a.dataset.dev);
1883
+ case 'job-new': return newJobSheet();
1884
+ case 'mem-new': return memEdit(null);
1885
+ case 'mem-all': if (mem) delete mem.hits; return loadMemory();
1886
+ case 'activity-refresh': activity = null; return loadActivity();
1431
1887
  case 'push-test': try { const r = await api('/api/push/test', { method: 'POST' }); toast(r.subs ? 'Enviat. Arribarà en uns segons.' : 'Primer activa els avisos'); } catch (err) { toast(err.message); } return;
1432
1888
  case 'add-computer': return addComputerSheet();
1433
1889
  case 'open-sessions': return go('#/sessions');
1434
1890
  case 'help': return go('#/ajuda');
1435
1891
  case 'more': return moreSheet();
1892
+ case 'folder': { const s = state.sessions.get(state.route.id); return s && folderSheet(s); }
1436
1893
  case 'machines': return machinesSheet();
1437
1894
  case 'mobile-qr': return mobileQrSheet();
1438
1895
  case 'mobile-hint-off': { const m = machine(); LS.set(`nio.mobHint.${m?.id || m?.url}`, true); return render(); }
@@ -1452,7 +1909,7 @@ document.addEventListener('submit', (e) => {
1452
1909
  e.preventDefault();
1453
1910
  const pair = pairFrom($('#pairInput', e.target).value);
1454
1911
  if (!pair) return toast('Aquest codi no és de connexió. Copia’l sencer del terminal.');
1455
- closeSheet(); addMachine(pair); toast('Connectant…'); go('#/sessions');
1912
+ closeSheet(); toast('Connectant…'); pairAndAdd(pair); go('#/sessions');
1456
1913
  });
1457
1914
 
1458
1915
  /* ---------------------------------------------------------------- paleta d'ordres */
@@ -1612,7 +2069,8 @@ $('#palette').addEventListener('close', () => setTimeout(() => tourDraw(false),
1612
2069
  // Al mòbil, l'alçada visible real (amb el teclat obert o tancat), perquè el camp de text quedi sempre just a sota.
1613
2070
  if (window.visualViewport) {
1614
2071
  const vv = visualViewport, root = document.documentElement;
1615
- const fit = () => { root.style.setProperty('--vvh', `${vv.height}px`); root.style.setProperty('--vvt', `${vv.offsetTop}px`); document.body.classList.toggle('kb', innerHeight - vv.height > 120); };
2072
+ // A l'iPhone el teclat no encongeix la pàgina: el camp d'escriure puja el que ocupi el teclat (--kb).
2073
+ const fit = () => { root.style.setProperty('--vvh', `${vv.height}px`); root.style.setProperty('--vvt', `${vv.offsetTop}px`); root.style.setProperty('--kb', `${Math.round(Math.max(0, innerHeight - vv.height - vv.offsetTop))}px`); document.body.classList.toggle('kb', innerHeight - vv.height > 120); };
1616
2074
  vv.addEventListener('resize', fit); vv.addEventListener('scroll', fit); fit();
1617
2075
  }
1618
2076
  if ('serviceWorker' in navigator) {
@@ -1636,3 +2094,59 @@ if (!checkPairHash()) state.route = parseRoute();
1636
2094
  if (machine()) connect();
1637
2095
  if (state.route.name === 'session') openSession(state.route.id); else render();
1638
2096
  if (T.on) tourGo(T.i); else if (!T.seen && !machine()) setTimeout(tourStart, 700);
2097
+
2098
+ /* ---------------------------------------------------------------- lliscar entre pàgines (mòbil) */
2099
+ (() => {
2100
+ const PAGES = ['sessions', 'approvals', 'ordinadors'];
2101
+ const view = $('#view');
2102
+ let g = null, timer = 0;
2103
+ const scrollsX = (el) => { for (; el && el !== view; el = el.parentElement) { if (el.scrollWidth > el.clientWidth + 2 && /auto|scroll/.test(getComputedStyle(el).overflowX)) return true; } return false; };
2104
+ const blocked = (t) => !mq.matches || document.body.classList.contains('nav-open') || $('#sheet')?.open || state.route.name === 'session' || t.closest?.('input, textarea, select, [contenteditable], [data-noswipe]') || scrollsX(t);
2105
+ const target = (dir) => PAGES[PAGES.indexOf(state.route.name) + dir];
2106
+ const put = (x, o) => { view.style.setProperty('--sx', x + 'px'); view.style.setProperty('--so', String(o)); };
2107
+ const clear = () => { view.classList.remove('swiping', 'swipe-anim'); view.style.removeProperty('--sx'); view.style.removeProperty('--so'); };
2108
+ addEventListener('touchstart', (e) => {
2109
+ g = null;
2110
+ if (e.touches.length !== 1 || !view.contains(e.target) || !PAGES.includes(state.route.name) || blocked(e.target)) return;
2111
+ const t = e.touches[0];
2112
+ if (t.clientX < 24 || t.clientX > innerWidth - 24) return;
2113
+ clearTimeout(timer); clear();
2114
+ g = { x: t.clientX, y: t.clientY, t: e.timeStamp, dx: 0, lock: null };
2115
+ }, { passive: true });
2116
+ addEventListener('touchmove', (e) => {
2117
+ if (!g) return;
2118
+ const t = e.touches[0], dx = t.clientX - g.x, dy = t.clientY - g.y;
2119
+ if (!g.lock) {
2120
+ if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;
2121
+ g.lock = Math.abs(dx) > Math.abs(dy) * 1.3 ? 'x' : 'y';
2122
+ if (g.lock === 'x') view.classList.add('swiping');
2123
+ }
2124
+ if (g.lock !== 'x') { g = null; return; }
2125
+ if (e.cancelable) e.preventDefault();
2126
+ g.dx = dx;
2127
+ const ok = target(dx < 0 ? 1 : -1);
2128
+ put(dx * (ok ? 0.8 : 0.2), 1 - Math.min(Math.abs(dx) / innerWidth, 0.6) * (ok ? 0.7 : 0.2));
2129
+ }, { passive: false });
2130
+ const end = (e) => {
2131
+ if (!g || g.lock !== 'x') { g = null; return; }
2132
+ const { dx } = g, v = Math.abs(dx) / Math.max(e.timeStamp - g.t, 1), dir = dx < 0 ? 1 : -1, to = target(dir);
2133
+ g = null;
2134
+ const far = Math.abs(dx) > 56 || (Math.abs(dx) > 20 && v > 0.35);
2135
+ if (!to || !far) {
2136
+ view.classList.add('swipe-anim'); put(0, 1);
2137
+ timer = setTimeout(clear, 200);
2138
+ return;
2139
+ }
2140
+ navigator.vibrate?.(6);
2141
+ view.classList.remove('swipe-anim');
2142
+ addEventListener('hashchange', () => {
2143
+ if (reduce) { clear(); return; }
2144
+ put(dir * innerWidth * 0.35, 0.2); view.offsetWidth;
2145
+ view.classList.add('swipe-anim'); put(0, 1);
2146
+ timer = setTimeout(clear, 200);
2147
+ }, { once: true });
2148
+ go(HASH[to]);
2149
+ };
2150
+ addEventListener('touchend', end, { passive: true });
2151
+ addEventListener('touchcancel', () => { if (g?.lock === 'x') { view.classList.add('swipe-anim'); put(0, 1); timer = setTimeout(clear, 200); } g = null; }, { passive: true });
2152
+ })();