nio-cat 0.9.13 → 0.9.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.9.13",
3
+ "version": "0.9.14",
4
4
  "description": "nio: fes servir Claude Code, Codex, Grok, Antigravity i Ollama del teu ordinador des del mòbil.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "f.cat",
package/src/telegram.js CHANGED
@@ -86,7 +86,13 @@ class Telegram {
86
86
  signal: this.ctrl.signal,
87
87
  });
88
88
  const j = await r.json();
89
- if (!j.ok) throw new Error(j.description || r.status);
89
+ if (!j.ok) {
90
+ // 409: un altre ordinador (o programa) llegeix el mateix bot. Telegram només en deixa un per bot.
91
+ if (j.error_code === 409 && !this.conflict) this.audit.add({ type: 'telegram', what: 'Un altre ordinador fa servir aquest mateix bot: cada ordinador en necessita un de propi' });
92
+ if (j.error_code === 409) this.conflict = Date.now();
93
+ throw new Error(j.description || r.status);
94
+ }
95
+ this.conflict = 0;
90
96
  for (const u of j.result) {
91
97
  this.offset = u.update_id + 1;
92
98
  await this.onUpdate(u).catch((e) => this.audit.add({ type: 'telegram', what: `Error: ${e.message}` }));
@@ -247,6 +253,7 @@ class Telegram {
247
253
  configured: !!t.token,
248
254
  bot: this.me ? { username: this.me.username, name: this.me.first_name } : null,
249
255
  running: this.running,
256
+ conflict: !!this.conflict && Date.now() - this.conflict < 5 * 60e3,
250
257
  allowed: t.allowed || [],
251
258
  pending: (t.pending || []).filter((p) => !this.allowed(p.id)),
252
259
  agent: t.agent || null,
@@ -281,6 +288,16 @@ class Telegram {
281
288
  t.token = prev;
282
289
  throw httpErr(400, `Telegram no accepta aquest testimoni: ${e.message}`);
283
290
  }
291
+ // Un bot només el pot llegir un ordinador alhora: si ja el fa servir un altre, cal un bot nou per a aquest.
292
+ // (Sense «offset», aquesta consulta no dona per llegit cap missatge.)
293
+ if (tok !== prev) {
294
+ const busy = await this.call('getUpdates', { timeout: 0, limit: 1 }).then(() => false, (e) => /conflict|409/i.test(e.message));
295
+ if (busy) {
296
+ t.token = prev;
297
+ this.me = null;
298
+ throw httpErr(409, 'Aquest bot ja el fa servir un altre ordinador. Telegram només deixa llegir un bot des d’un lloc: crea’n un altre a @BotFather (/newbot) per a aquest ordinador.');
299
+ }
300
+ }
284
301
  this.stop();
285
302
  this.offset = undefined;
286
303
  }
@@ -0,0 +1,37 @@
1
+ // nio dins l'app d'Android: l'únic lloc on la web s'adapta a l'app.
2
+ // La resta del codi fa servir les APIs estàndard del navegador i no sap que és dins una app; aquí es tradueixen
3
+ // a la part nativa (window.NioAndroid, a android/src/cat/nio/app/MainActivity.java) les que el WebView no té.
4
+ // Fora de l'app, aquest fitxer no fa res. Per afegir-hi una cosa nova: un mètode a Bridge (Java) i el pont aquí.
5
+ (() => {
6
+ const NA = window.NioAndroid;
7
+ if (!NA) return;
8
+ document.documentElement.classList.add('android');
9
+
10
+ // Descàrregues (<a download href="blob:…">: exportar la pissarra, desar una captura…) → carpeta Baixades del mòbil.
11
+ const base64 = (blob) => new Promise((ok, ko) => { const r = new FileReader(); r.onload = () => ok(String(r.result).split(',')[1] || ''); r.onerror = ko; r.readAsDataURL(blob); });
12
+ async function save(href, name) {
13
+ try { const blob = await (await fetch(href)).blob(); NA.saveFile(name || 'nio', blob.type || 'application/octet-stream', await base64(blob)); }
14
+ catch { NA.saveFile('', '', ''); } // l'app avisa que no s'ha pogut desar
15
+ }
16
+ const isBlob = (a) => a.hasAttribute('download') && /^(blob|data):/.test(a.href);
17
+ if (NA.saveFile) { // les versions de l'app anteriors a la 1.0.2 no ho tenen
18
+ const click = HTMLAnchorElement.prototype.click;
19
+ HTMLAnchorElement.prototype.click = function () { if (isBlob(this)) return void save(this.href, this.getAttribute('download')); return click.call(this); };
20
+ document.addEventListener('click', (e) => { const a = e.target.closest?.('a[download]'); if (a && isBlob(a)) { e.preventDefault(); save(a.href, a.getAttribute('download')); } }, true);
21
+ }
22
+
23
+ // Copiar: el porta-retalls del WebView no sempre deixa escriure; el d'Android sí.
24
+ if (!NA.copy) { /* app antiga */ } else if (navigator.clipboard) navigator.clipboard.writeText = (t) => Promise.resolve(NA.copy(String(t)));
25
+ else Object.defineProperty(navigator, 'clipboard', { value: { writeText: (t) => Promise.resolve(NA.copy(String(t))) }, configurable: true });
26
+
27
+ // Pantalla encesa: el WebView no sempre té navigator.wakeLock; l'app manté la pantalla encesa mentre hi ha algun bloqueig.
28
+ if (NA.keepAwake) {
29
+ const held = new Set();
30
+ const lock = { request: async () => {
31
+ const s = { released: false, type: 'screen', onrelease: null, addEventListener() {}, removeEventListener() {},
32
+ async release() { if (s.released) return; s.released = true; held.delete(s); if (!held.size) NA.keepAwake(false); s.onrelease?.(); } };
33
+ held.add(s); NA.keepAwake(true); return s;
34
+ } };
35
+ try { Object.defineProperty(navigator, 'wakeLock', { value: lock, configurable: true }); } catch { /* es queda el del WebView */ }
36
+ }
37
+ })();
package/web/app/app.js CHANGED
@@ -224,7 +224,7 @@ async function pollLoop(ctrl) {
224
224
  const [list, aps] = await Promise.all([api('/api/sessions'), api('/api/approvals')]);
225
225
  if (ctrl.signal.aborted) return;
226
226
  state.connected = true; state.lastError = null; alive = Date.now();
227
- 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(); }
227
+ 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(); saveSnap(); }
228
228
  else {
229
229
  const r = state.route, seen = new Set();
230
230
  for (const x of list) {
@@ -290,14 +290,53 @@ async function relocate(i) {
290
290
  } catch { return false; }
291
291
  }
292
292
 
293
+ /* ---------- Instantània de cada ordinador
294
+ En canviar d'ordinador es pinta a l'instant el que ja en sabíem (sessions, aprovacions, ús i info) i es posa al dia
295
+ quan arriba l'«hello». Mentre la llista d'ordinadors és oberta, els altres es refresquen en segon pla. */
296
+ const SNAP_KEY = 'nio.snap';
297
+ const snaps = new Map(Object.entries(LS.get(SNAP_KEY, {}) || {}));
298
+ let snapT = 0;
299
+ const persistSnaps = () => LS.set(SNAP_KEY, Object.fromEntries([...snaps].filter(([k]) => store.machines.some((m) => mkey(m) === k))));
300
+ function saveSnap() {
301
+ const m = machine(); if (!m || isDemo(m) || !state.synced) return;
302
+ clearTimeout(snapT);
303
+ snapT = setTimeout(() => {
304
+ if (mkey(machine()) !== mkey(m) || !state.synced) return;
305
+ snaps.set(mkey(m), { t: Date.now(), info: state.info, usage: state.usage || null, approvals: [...state.approvals.values()], sessions: [...state.sessions.values()].sort((a, b) => b.updated - a.updated).slice(0, 80) });
306
+ persistSnaps();
307
+ }, 500);
308
+ }
309
+ function restoreSnap(m) {
310
+ const s = snaps.get(mkey(m)); if (!s) return false;
311
+ state.info = s.info || null; state.usage = s.usage || null;
312
+ state.sessions = new Map((s.sessions || []).map((x) => [x.id, x])); state.approvals = new Map((s.approvals || []).map((a) => [a.id, a]));
313
+ return true;
314
+ }
315
+ const snapBusy = new Set();
316
+ async function refreshSnaps() {
317
+ await Promise.all(store.machines.map(async (m) => {
318
+ const k = mkey(m);
319
+ if (m === machine() || isDemo(m) || snapBusy.has(k) || Date.now() - (snaps.get(k)?.t || 0) < 15000) return;
320
+ snapBusy.add(k);
321
+ try {
322
+ const get = (p) => fetch(`${m.url}${p}`, { headers: { Authorization: `Bearer ${m.token}` }, cache: 'no-store', signal: AbortSignal.timeout(6000) }).then((r) => { if (!r.ok) throw new Error(r.status); return r.json(); });
323
+ const [info, sessions, approvals, usage] = await Promise.all([get('/api/info'), get('/api/sessions'), get('/api/approvals'), get('/api/usage').catch(() => null)]);
324
+ if (m === machine()) return;
325
+ snaps.set(k, { t: Date.now(), info, usage, approvals, sessions: sessions.sort((a, b) => b.updated - a.updated).slice(0, 80) }); persistSnaps();
326
+ } catch { /* no hi arribo: es queda la d'abans */ } finally { snapBusy.delete(k); }
327
+ }));
328
+ }
329
+
293
330
  async function connect() {
294
331
  streamCtrl?.abort(); state.synced = false;
295
332
  const m = machine();
296
333
  if (!m) { state.connected = false; return render(); }
297
334
  const ctrl = new AbortController(); streamCtrl = ctrl;
298
335
  try {
299
- state.info = await api('/api/info');
300
- const res = await fetch(`${m.url}/api/stream?d=${encodeURIComponent(deviceId())}&k=${isPhone() ? 'phone' : 'desktop'}`, { headers: { Authorization: `Bearer ${m.token}` }, signal: ctrl.signal, cache: 'no-store' });
336
+ // La info i el flux en paral·lel: el canvi d'ordinador no espera dues anades i tornades seguides.
337
+ const [info, res] = await Promise.all([api('/api/info'), fetch(`${m.url}/api/stream?d=${encodeURIComponent(deviceId())}&k=${isPhone() ? 'phone' : 'desktop'}`, { headers: { Authorization: `Bearer ${m.token}` }, signal: ctrl.signal, cache: 'no-store' })]);
338
+ if (ctrl.signal.aborted) return;
339
+ state.info = info;
301
340
  if (res.status === 401) throw Object.assign(new Error('La clau no és vàlida. Torna a connectar amb el codi QR.'), { fatal: true });
302
341
  if (!res.ok || !res.body) throw new Error(`Error ${res.status}`);
303
342
  state.connected = true; state.lastError = null; retry = 0; alive = Date.now();
@@ -372,6 +411,7 @@ function renameMachine(host, id) {
372
411
  function handle(msg) {
373
412
  const r = state.route;
374
413
  callCtl?.onEvent(msg);
414
+ if (/^(session|removed|approval|usage)$/.test(msg.type)) saveSnap();
375
415
  switch (msg.type) {
376
416
  case 'device':
377
417
  if (!isPhone() && msg.device !== deviceId()) { if ($('#qrBox')) closeSheet(); toast('Mòbil connectat correctament', 2000); }
@@ -382,6 +422,7 @@ function handle(msg) {
382
422
  state.approvals = new Map(msg.approvals.map((a) => [a.id, a])); state.usage = msg.usage || state.usage; notesCtl?.reload();
383
423
  state.loaded.clear(); state.events.clear();
384
424
  if (r.name === 'session') openSession(r.id); else render();
425
+ saveSnap(); if (store.machines.length > 1) setTimeout(refreshSnaps, 4000);
385
426
  break;
386
427
  case 'session':
387
428
  state.sessions.set(msg.session.id, msg.session);
@@ -493,8 +534,10 @@ async function pairAndAdd(pair, { msg = 'Ordinador connectat', after } = {}) {
493
534
  }
494
535
  // Passa a un altre ordinador de la llista i es queda a la mateixa secció.
495
536
  function switchMachine(i) {
496
- closeSheet(); store.active = i; streamCtrl?.abort(); tgState = undefined; nodeVoice = null;
497
- state.ollama = null; state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.connected = false; state.info = null; state.lastError = null;
537
+ closeSheet(); store.active = i; streamCtrl?.abort(); tgState = undefined; tgOthers = []; nodeVoice = null;
538
+ state.ollama = null; state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.connected = false; state.info = null; state.usage = null; state.lastError = null;
539
+ restoreSnap(machine()); // el que ja en sabíem, a l'instant; l'«hello» ho posa al dia
540
+ if (state.route.name === 'notes' && !notesCtl?.open) lastName = null; // les notes de l'altre ordinador es tornen a muntar (a l'instant si ja les coneixíem)
498
541
  connect();
499
542
  if (state.route.name === 'session') go('#/sessions'); else render();
500
543
  }
@@ -576,7 +619,7 @@ function renderChrome() {
576
619
  const s = r.name === 'session' ? state.sessions.get(r.id) : null;
577
620
  $('#here').textContent = s ? s.title || 'Sessió nova' : VIEWS[r.name]?.label || '';
578
621
  $('#mRight').innerHTML = r.name === 'session'
579
- ? `<button class="icon-btn" data-act="session-more" aria-label="Opcions de la sessió">${svg(ICON.more)}</button>`
622
+ ? `${usageChip(s?.agent, true)}<button class="icon-btn" data-act="session-more" aria-label="Opcions de la sessió">${svg(ICON.more)}</button>`
580
623
  : `<a class="icon-btn" href="#/aprovacions" aria-label="${n ? `Aprovacions, ${n} pendents` : 'Aprovacions'}">${svg(ICON.bell)}${n ? `<span class="badge">${n}</span>` : ''}</a>`;
581
624
  document.title = n ? `(${n}) nio` : `nio // ${s ? s.title || 'Sessió' : VIEWS[r.name]?.label || 'Panell'}`;
582
625
  if ('setAppBadge' in navigator) (n ? navigator.setAppBadge(n) : navigator.clearAppBadge()).catch?.(() => {});
@@ -905,6 +948,7 @@ function addComputerSheet(tab, opts = {}) {
905
948
  }
906
949
  function machinesSheet() {
907
950
  const ms = store.machines;
951
+ refreshSnaps();
908
952
  openSheet(`<h2>Ordinadors</h2>${ms.length ? `<div class="stack">${ms.map((x, i) => `<button class="card row" type="button" data-machine="${i}" style="flex-wrap:nowrap"><span class="c-ico">${svg(machineIcon(x))}</span><span class="grow"><b>${esc(x.name)}</b><div class="muted small">${esc(machineSub(x))}</div></span>${i === store.active ? `<span class="chip ok"><i class="dot ${state.connected ? 'on' : ''}"></i>Actiu</span>` : ''}</button>`).join('')}</div>` : '<p class="muted small">Encara no hi ha cap ordinador.</p>'}
909
953
  <div class="row between sheet-foot">${ms.length ? '<a class="btn ghost" href="#/ordinadors" data-close>Gestiona</a>' : '<button class="btn ghost" type="button" data-close>Tanca</button>'}<button class="btn ink" type="button" data-act="add-computer">${svg(ICON.plus)}Afegeix ordinador</button></div>`, (sh) => { $$('[data-close]', sh).forEach((b) => (b.onclick = closeSheet)); });
910
954
  }
@@ -1327,12 +1371,14 @@ function renderSession() {
1327
1371
  function sessionMood(s) { return !s ? 'idle' : s.state === 'running' ? 'work' : s.state === 'waiting' || s.pending ? 'peek' : s.state === 'error' ? 'sleep' : 'idle'; }
1328
1372
  // Ús de la subscripció de l'agent (5 h i setmana), com a esfis. Només si l'agent l'ha informat.
1329
1373
  const hhmm = (t) => new Date(t * 1000).toLocaleString('ca', { weekday: t * 1000 - Date.now() > 864e5 ? 'short' : undefined, hour: '2-digit', minute: '2-digit' });
1330
- function usageChip(agent) {
1374
+ // mini: la versió de la barra de dalt del mòbil (l'anell i la finestra més plena; tocant-la, el detall).
1375
+ function usageChip(agent, mini) {
1331
1376
  const u = state.usage?.[agent]; if (!u?.windows?.length) return '';
1332
1377
  const tip = u.windows.map((w) => `${w.label}: ${Math.round(w.used * 100)} %${w.resetsAt ? ` · es reinicia ${hhmm(w.resetsAt)}` : ''}`).join('\n');
1333
1378
  // Etiquetes curtes: «5h», «7d». La icona és un anell que s'omple amb la finestra més plena.
1334
1379
  const short = (l) => (l === 'setmana' ? '7d' : l.replace(/\s+/g, '')), top = Math.max(...u.windows.map((w) => w.used)), pct = Math.round(top * 100);
1335
1380
  const ring = `<svg class="ico ring" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8.5" class="rt"/><circle cx="12" cy="12" r="8.5" pathLength="100" stroke-dasharray="${Math.max(pct, 2)} 100" transform="rotate(-90 12 12)" class="rv"/></svg>`;
1381
+ if (mini) { const w = u.windows.find((x) => x.used === top); return `<button class="chip mono usage mini" type="button" data-act="usage" data-hi="${top >= 0.8 ? 1 : 0}" aria-label="${esc(`Ús de la subscripció. ${tip.replace(/\n/g, '. ')}`)}">${ring}<span class="uw" data-hi="${top >= 0.8 ? 1 : 0}">${esc(short(w.label))} ${pct}%</span></button>`; }
1336
1382
  return `<span class="chip mono usage" data-hi="${top >= 0.8 ? 1 : 0}" title="${esc(`Ús de la subscripció\n${tip}`)}">${ring}${u.windows.map((w) => `<span class="uw" data-hi="${w.used >= 0.8 ? 1 : 0}">${esc(short(w.label))} (${Math.round(w.used * 100)}%)</span>`).join('')}</span>`;
1337
1383
  }
1338
1384
  function renderSessionHead() {
@@ -2551,9 +2597,16 @@ document.addEventListener('click', async (e) => {
2551
2597
 
2552
2598
  /* ---------------------------------------------------------------- Telegram (bot propi, configurat a l'ordinador) */
2553
2599
  let tgState;
2600
+ let tgOthers = []; // bots dels altres ordinadors: [{ name, bot, allowed }]
2554
2601
  async function loadTelegram() {
2555
2602
  try { tgState = await api('/api/telegram'); } catch { tgState = null; }
2556
2603
  const box = $('#tgPanel'); if (box) box.innerHTML = telegramPanel();
2604
+ const mine = machine(), others = store.machines.filter((m) => m !== mine && mkey(m) !== mkey(mine) && !isDemo(m));
2605
+ const got = await Promise.all(others.map((m) => fetch(`${m.url}/api/telegram`, { headers: { Authorization: `Bearer ${m.token}` }, cache: 'no-store', signal: AbortSignal.timeout(5000) })
2606
+ .then((r) => (r.ok ? r.json() : null)).then((t) => (t?.configured ? { name: m.name, bot: t.bot?.username || '', allowed: t.allowed || [] } : null)).catch(() => null)));
2607
+ if (machine() !== mine && mkey(machine()) !== mkey(mine)) return;
2608
+ tgOthers = got.filter(Boolean);
2609
+ if (tgOthers.length && $('#tgPanel')) $('#tgPanel').innerHTML = telegramPanel();
2557
2610
  }
2558
2611
  function telegramPanel() {
2559
2612
  if (!machine()) return '';
@@ -2562,9 +2615,11 @@ function telegramPanel() {
2562
2615
  if (t === null) return '<section class="panel"><header><h2>Telegram</h2></header><div class="body"><p class="muted small">Aquest ordinador encara no té Telegram (cal nio 0.7 o més nou).</p></div></section>';
2563
2616
  if (!t.configured) return `<section class="panel"><header><h2>Telegram</h2></header><div class="body stack">
2564
2617
  <p class="small">Parla amb els agents d’aquest ordinador des de Telegram: instruccions, notes de veu, avisos i aprovacions amb botons. El bot és teu i el testimoni es queda a l’ordinador.</p>
2565
- <ol class="muted small" style="padding-left:1.2em"><li>A Telegram, obre <b>@BotFather</b> i envia <span class="mono">/newbot</span>.</li><li>Copia el testimoni que et dona i enganxa’l aquí.</li><li>Escriu qualsevol cosa al teu bot i torna aquí per autoritzar el teu xat.</li></ol>
2618
+ ${tgOthers.length ? `<p class="small tg-note">Cada ordinador necessita <b>el seu bot</b>: Telegram només deixa llegir un bot des d’un lloc. ${tgOthers.map((o) => `<b>${esc(o.name)}</b> ja fa servir ${o.bot ? '@' + esc(o.bot) : 'un bot'}`).join(', ')}; crea’n un altre per a aquest${tgOthers.some((o) => o.allowed.length) ? ' i els teus xats hi quedaran autoritzats sols' : ''}.</p>` : ''}
2619
+ <ol class="muted small" style="padding-left:1.2em"><li>A Telegram, obre <b>@BotFather</b> i envia <span class="mono">/newbot</span>${tgOthers.length ? ' (un nom diferent, p. ex. amb el nom de l’ordinador)' : ''}.</li><li>Copia el testimoni que et dona i enganxa’l aquí.</li><li>Escriu qualsevol cosa al teu bot i torna aquí per autoritzar el teu xat.</li></ol>
2566
2620
  <form class="row" id="tgForm" style="flex-wrap:nowrap"><input type="password" id="tgToken" placeholder="123456:ABC…" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Testimoni del bot"><button class="btn ink" type="submit">Connecta</button></form></div></section>`;
2567
2621
  return `<section class="panel"><header><h2>Telegram</h2><span class="chip ok"><i class="dot on"></i>${esc(t.bot ? '@' + t.bot.username : 'Connectat')}</span></header><div class="body stack">
2622
+ ${t.conflict ? '<p class="small form-error">Un altre ordinador llegeix aquest mateix bot i es reparteixen els missatges. Cada ordinador necessita el seu: desconnecta’l d’un dels dos i crea’n un altre a @BotFather.</p>' : ''}
2568
2623
  ${t.pending.length ? `<div class="stack"><b class="small">Volen parlar amb el bot</b>${t.pending.map((p) => `<div class="agent-row"><span class="grow"><b>${esc(p.name)}</b><div class="muted small">${p.username ? '@' + esc(p.username) + ' · ' : ''}${ago(p.t)} · xat ${esc(p.id)}</div></span><button class="btn sm ink" data-tg-allow="${esc(p.id)}" data-name="${esc(p.name)}" type="button">Autoritza</button></div>`).join('')}<p class="muted small">Autoritza només els xats que reconeguis. La resta s’ignoren.</p></div>` : ''}
2569
2624
  <div class="stack"><b class="small">Xats autoritzats</b>${t.allowed.length ? t.allowed.map((a) => `<div class="agent-row"><span class="grow"><b>${esc(a.name)}</b><div class="muted small">xat ${esc(a.id)}</div></span><button class="btn sm danger" data-tg-drop="${esc(a.id)}" type="button">Treu</button></div>`).join('') : `<p class="muted small">Encara cap. Escriu al bot ${t.bot ? '<b>@' + esc(t.bot.username) + '</b>' : ''} i el teu xat apareixerà aquí.</p>`}</div>
2570
2625
  <div class="grid-2"><label class="f"><span>Agent</span><select id="tgAgent">${['claude', 'codex'].map((a) => `<option value="${a}" ${t.agent === a ? 'selected' : ''}>${AGENT_NAMES[a]}</option>`).join('')}</select></label>
@@ -2575,7 +2630,10 @@ function telegramPanel() {
2575
2630
  }
2576
2631
  document.addEventListener('submit', async (e) => {
2577
2632
  if (e.target.id !== 'tgForm') return; e.preventDefault();
2578
- try { tgState = await api('/api/telegram', { method: 'PUT', body: { token: $('#tgToken').value.trim() } }); toast('Bot connectat'); } catch (err) { toast(err.message, 5000); }
2633
+ try { tgState = await api('/api/telegram', { method: 'PUT', body: { token: $('#tgToken').value.trim() } }); toast('Bot connectat'); } catch (err) { toast(err.message, 7000); }
2634
+ // Els xats que ja parlen amb els bots dels altres ordinadors també poden parlar amb aquest.
2635
+ const known = new Map(tgOthers.flatMap((o) => o.allowed).map((a) => [String(a.id), a]));
2636
+ if (tgState?.configured) for (const a of known.values()) if (!tgState.allowed.some((x) => String(x.id) === String(a.id))) tgState = await api('/api/telegram/allow', { method: 'POST', body: { id: Number(a.id), name: a.name } }).catch(() => tgState);
2579
2637
  $('#tgPanel').innerHTML = telegramPanel();
2580
2638
  });
2581
2639
  document.addEventListener('change', async (e) => {
@@ -2790,6 +2848,7 @@ document.addEventListener('click', async (e) => {
2790
2848
  case 'model': { const s = state.sessions.get(state.route.id); if (s) modelSheet(s); return; }
2791
2849
  case 'mode': { const s = state.sessions.get(state.route.id); if (s) modeSheet(s); return; }
2792
2850
  case 'session-more': return sessionOptions(state.route.id);
2851
+ case 'usage': { const u = state.usage?.[state.sessions.get(state.route.id)?.agent]; if (u?.windows) toast(`Ús de la subscripció · ${u.windows.map((w) => `${w.label}: ${Math.round(w.used * 100)} %${w.resetsAt ? ` (es reinicia ${hhmm(w.resetsAt)})` : ''}`).join(' · ')}`, 5000); return; }
2793
2852
  case 'refresh-agents': try { state.info = await api('/api/info?refresh=1'); render(); toast('Agents actualitzats'); } catch (err) { toast(err.message); } return;
2794
2853
  case 'push': return enablePush();
2795
2854
  case 'push-off': return disablePush();
@@ -3100,7 +3159,7 @@ if (['127.0.0.1', 'localhost', '[::1]'].includes(location.hostname)) {
3100
3159
  }
3101
3160
  setNav(false);
3102
3161
  if (!checkPairHash()) state.route = parseRoute();
3103
- if (machine()) connect();
3162
+ if (machine()) { restoreSnap(machine()); connect(); }
3104
3163
  if (state.route.name === 'session') openSession(state.route.id); else render();
3105
3164
  if (T.on) tourGo(T.i); else if (!T.seen && !machine()) setTimeout(tourStart, 700);
3106
3165
 
@@ -33,6 +33,7 @@
33
33
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
34
34
  <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600&family=Caveat:wght@500;600&display=swap" rel="stylesheet">
35
35
  <script>/* App d'Android: la pàgina va per sota de les barres del sistema; l'app en diu la mida abans de pintar. */try{var i=JSON.parse(NioAndroid.insets()),d=document.documentElement;d.style.setProperty('--safe-t',i.t+'px');d.style.setProperty('--safe-b',i.b+'px');d.classList.add('android')}catch(e){}</script>
36
+ <script src="/app/android.js"></script>
36
37
  <link rel="stylesheet" href="/bot.css">
37
38
  <link rel="stylesheet" href="/board.css">
38
39
  <link rel="stylesheet" href="/app/style.css">
package/web/app/notes.css CHANGED
@@ -145,6 +145,12 @@ body:has(.nt-ov) { overflow: hidden; }
145
145
  .nt-more-sheet button.danger { color: var(--red); }
146
146
  .nt-more-sheet button.muted { color: var(--fg-muted); text-align: center; }
147
147
 
148
+ /* Al mòbil, el panell «Més» i els desplegables són fulls de baix que segueixen el dit: el navegador no s'ho queda per desplaçar. */
149
+ .nt-more-sheet, .nt-pop-sheet { touch-action: none; }
150
+ .nt-pop.nt-pop-sheet { position: absolute; z-index: 6; left: 0 !important; right: 0; bottom: 0 !important; top: auto; min-width: 0; max-width: none; max-height: 75% !important; overflow: auto; overscroll-behavior: contain; padding: 6px 12px calc(14px + var(--safe-b)); border: 0; border-radius: 18px 18px 0 0; box-shadow: 0 -8px 30px rgba(39,34,36,.14); animation: nt-sheet-up .28s cubic-bezier(.22,1,.36,1); }
151
+ .nt-pop-sheet input, .nt-pop-sheet select { touch-action: manipulation; }
152
+ @keyframes nt-sheet-up { from { transform: translateY(100%); } }
153
+
148
154
  /* finestretes */
149
155
  .nt-pop { position: absolute; z-index: 5; min-width: 220px; max-width: min(340px, calc(100% - 16px)); padding: 8px; border: 1px solid var(--border); border-radius: 14px; background: var(--white); box-shadow: 0 18px 50px -14px rgba(39,34,36,.5); animation: nt-pop .18s var(--ease); transform-origin: bottom left; }
150
156
  @keyframes nt-pop { from { opacity: 0; transform: translateY(6px) scale(.97); } }
package/web/app/notes.js CHANGED
@@ -54,6 +54,7 @@ export function createNotes(ctx) {
54
54
  let view = 'notes', q = '', layout = LSget('nio.notes.layout', 'grid');
55
55
  let ed = null, aborter = null;
56
56
  const historyNotes = new Map();
57
+ const byMachine = new Map(); // ordinador -> notes que en coneixem
57
58
  const pendingSaves = new Set();
58
59
  let loadError = '';
59
60
  const machineKey = () => ctx.machine()?.id || ctx.machine()?.url;
@@ -87,6 +88,7 @@ export function createNotes(ctx) {
87
88
  const r = await api('/api/notes');
88
89
  if (requestedMachine !== machineKey()) { loading = false; if (root) load(); return; }
89
90
  notes = new Map(r.notes.map((n) => [n.id, n])); loaded = true;
91
+ byMachine.set(requestedMachine, notes);
90
92
  for (const entry of pendingSaves) if (entry.id && entry.machineKey === machineKey()) notes.set(entry.id, entry.n);
91
93
  if (ed?.id) notes.set(ed.id, ed.n);
92
94
  }
@@ -101,7 +103,12 @@ export function createNotes(ctx) {
101
103
  // ---------------------------------------------------------------- pàgina
102
104
  function mount(el) {
103
105
  root = el; aborter?.abort(); aborter = new AbortController();
104
- if (notesMachine !== machineKey()) { notesMachine = machineKey(); notes.clear(); loaded = false; engines = null; q = ''; view = 'notes'; }
106
+ if (notesMachine !== machineKey()) {
107
+ // Les notes de l'ordinador d'abans es queden a la memòria: si hi tornes, surten a l'instant i es posen al dia.
108
+ if (loaded) byMachine.set(notesMachine, notes);
109
+ notesMachine = machineKey(); engines = null; q = ''; view = 'notes';
110
+ notes = byMachine.get(notesMachine) || new Map(); loaded = byMachine.has(notesMachine);
111
+ }
105
112
  if (history.state?.nioNote) { const { nioNote, ...rest } = history.state; history.replaceState(rest, '', location.href); }
106
113
  root.innerHTML = `<div class="nt">
107
114
  <div class="nt-top">
@@ -491,11 +498,11 @@ export function createNotes(ctx) {
491
498
  title.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); ($('.nt-ed-text', c) || $('.nt-item input[type=text]', c))?.focus(); } });
492
499
  c.addEventListener('click', onEditorClick);
493
500
  drawBody(); drawAudio(); drawLabels(); drawMode();
494
- bindMoreDrag($('#edMore', c), $('#edScrim', c));
501
+ bindMoreDrag($('#edMore', c), $('#edScrim', c), () => closeMore(true));
495
502
  }
496
503
  // El panell «Més» segueix el dit cap avall i es tanca si baixa prou o es llença; si no, torna a pujar.
497
504
  let moreDraggedAt = 0;
498
- function bindMoreDrag(sheet, scrim) {
505
+ function bindMoreDrag(sheet, scrim, close) {
499
506
  let g = null;
500
507
  sheet.addEventListener('pointerdown', (e) => { if (e.pointerType === 'mouse') return; g = { y0: e.clientY, dy: 0, pid: e.pointerId, pts: [[e.timeStamp, e.clientY]], moved: false }; }, { passive: true });
501
508
  sheet.addEventListener('pointermove', (e) => {
@@ -512,7 +519,7 @@ export function createNotes(ctx) {
512
519
  const d = g; g = null; if (!d.moved) return;
513
520
  moreDraggedAt = Date.now();
514
521
  const [t0, y0] = d.pts[0], [t1, y1] = d.pts[d.pts.length - 1], v = (y1 - y0) / Math.max(1, t1 - t0);
515
- if (d.dy > sheet.offsetHeight * 0.3 || (v > 0.4 && d.dy > 12)) closeMore(true);
522
+ if (d.dy > sheet.offsetHeight * 0.3 || (v > 0.4 && d.dy > 12)) close();
516
523
  else { sheet.style.transition = ''; scrim.style.transition = ''; sheet.style.transform = ''; scrim.style.opacity = ''; }
517
524
  };
518
525
  sheet.addEventListener('pointerup', end); sheet.addEventListener('pointercancel', end);
@@ -575,7 +582,7 @@ export function createNotes(ctx) {
575
582
  async function onEditorClick(ev) {
576
583
  const b = ev.target.closest('[data-e]'), c = ed?.card; if (!b || !ed) { if (ed?.pop && !ev.target.closest('.nt-pop')) closePop(); if (ed && !ev.target.closest('.nt-more-sheet')) closeMore(true); return; }
577
584
  const a = b.dataset.e;
578
- if (ev.target.closest('.nt-more-sheet') && Date.now() - moreDraggedAt < 350) return; // era un arrossegament, no un toc
585
+ if (ev.target.closest('.nt-more-sheet, .nt-pop-sheet') && Date.now() - moreDraggedAt < 350) return; // era un arrossegament, no un toc
579
586
  if (a === 'more-close') return closeMore(true);
580
587
  if (a !== 'more' && !ev.target.closest('.nt-more-sheet')) closeMore(true);
581
588
  if (a === 'retry-save') { b.disabled = true; await flush(ed); b.disabled = false; return; }
@@ -624,10 +631,28 @@ export function createNotes(ctx) {
624
631
  s.style.transform = 'translateY(105%)'; sc.style.opacity = '0';
625
632
  setTimeout(hide, 220);
626
633
  }
627
- function closePop() { ed?.pop?.remove(); if (ed) ed.pop = null; }
634
+ function closePop(animated) {
635
+ const p = ed?.pop; if (!p) return; ed.pop = null;
636
+ const sc = p.previousElementSibling?.classList.contains('nt-pop-scrim') ? p.previousElementSibling : null;
637
+ if (!animated || reduce || !p.classList.contains('nt-pop-sheet')) { p.remove(); sc?.remove(); return; }
638
+ p.style.transition = 'transform .22s cubic-bezier(.4,0,1,1)'; p.style.transform = 'translateY(105%)';
639
+ if (sc) { sc.style.transition = 'opacity .22s'; sc.style.opacity = '0'; }
640
+ setTimeout(() => { p.remove(); sc?.remove(); }, 220);
641
+ }
642
+ // Al mòbil, els desplegables (color, etiqueta, assistent) són fulls de baix: a l'amplada, amb nansa, i es tanquen lliscant cap avall.
643
+ const phone = matchMedia('(max-width: 700px)');
644
+ const popGrab = (p) => (p.classList.contains('nt-pop-sheet') ? '<div class="nt-more-grab"></div>' : '');
628
645
  function pop(anchor, html) {
629
646
  closePop();
630
647
  const p = document.createElement('div'); p.className = 'nt-pop'; p.innerHTML = html;
648
+ if (phone.matches) {
649
+ const sc = document.createElement('div'); sc.className = 'nt-more-scrim nt-pop-scrim';
650
+ sc.addEventListener('click', () => closePop(true));
651
+ p.classList.add('nt-pop-sheet'); p.setAttribute('role', 'dialog'); p.insertAdjacentHTML('afterbegin', '<div class="nt-more-grab"></div>');
652
+ ed.card.append(sc, p); ed.pop = p;
653
+ bindMoreDrag(p, sc, () => closePop(true));
654
+ return p;
655
+ }
631
656
  ed.card.append(p); ed.pop = p;
632
657
  const a = anchor.getBoundingClientRect(), r = ed.card.getBoundingClientRect(), w = p.offsetWidth;
633
658
  p.style.maxHeight = `${Math.max(80, a.top - Math.max(r.top, window.visualViewport?.offsetTop || 0) - 16)}px`;
@@ -636,14 +661,14 @@ export function createNotes(ctx) {
636
661
  }
637
662
  function popColor(b) {
638
663
  const p = pop(b, `<div class="nt-colors">${COLORS.map(([k, l]) => `<button type="button" class="nt-dot" data-color="${k}" aria-label="${l}" title="${l}" ${ed.n.color === k ? 'aria-pressed="true"' : ''}></button>`).join('')}</div>`);
639
- p.addEventListener('click', (e) => { const d = e.target.closest('.nt-dot'); if (d) { set({ color: d.dataset.color }); closePop(); } });
664
+ p.addEventListener('click', (e) => { const d = Date.now() - moreDraggedAt < 350 ? null : e.target.closest('.nt-dot'); if (d) { set({ color: d.dataset.color }); closePop(); } });
640
665
  }
641
666
  function popLabel(b) {
642
667
  const mine = new Set(ed.n.labels), labs = labelsOf().filter((l) => !mine.has(l));
643
668
  const p = pop(b, `<form class="nt-lf"><input type="text" placeholder="Nova etiqueta" maxlength="30" aria-label="Nova etiqueta" autocomplete="off"><button class="btn ink sm" type="submit">Afegeix</button></form>${labs.length ? `<div class="nt-lsug">${labs.map((l) => `<button type="button" class="nt-chip" data-l="${esc(l)}">${esc(l)}</button>`).join('')}</div>` : ''}`);
644
669
  const add = (l) => { l = l.trim().toLowerCase(); if (!l || ed.n.labels.includes(l) || ed.n.labels.length >= 12) return; set({ labels: [...ed.n.labels, l] }); drawLabels(); closePop(); };
645
670
  $('form', p).onsubmit = (e) => { e.preventDefault(); add($('input', p).value); };
646
- p.addEventListener('click', (e) => { const c = e.target.closest('[data-l]'); if (c) add(c.dataset.l); });
671
+ p.addEventListener('click', (e) => { const c = Date.now() - moreDraggedAt < 350 ? null : e.target.closest('[data-l]'); if (c) add(c.dataset.l); });
647
672
  setTimeout(() => $('input', p).focus(), 30);
648
673
  }
649
674
 
@@ -698,19 +723,19 @@ export function createNotes(ctx) {
698
723
  async function popAi(b) {
699
724
  const p = pop(b, '<p class="muted small nt-pad">Mirant quins models hi ha…</p>');
700
725
  const list = await loadEngines(); if (ed?.pop !== p) return;
701
- if (!list.length) { p.innerHTML = '<p class="small nt-pad">No hi ha cap model en aquest ordinador.<br>Instal·la <b>Ollama</b> i un model (<span class="mono">ollama pull qwen3:4b</span>) o <b>Claude Code</b>.</p>'; return; }
726
+ if (!list.length) { p.innerHTML = popGrab(p) + '<p class="small nt-pad">No hi ha cap model en aquest ordinador.<br>Instal·la <b>Ollama</b> i un model (<span class="mono">ollama pull qwen3:4b</span>) o <b>Claude Code</b>.</p>'; return; }
702
727
  // Per defecte: el model local més petit de 3 mil milions de paràmetres o més; si no, Claude ràpid; si no, el primer.
703
728
  const auto = (list.find((e) => e.local && e.id.startsWith('ollama:') && e.params >= 3) || list.find((e) => e.id === 'claude:haiku') || list[0]).id;
704
729
  const saved0 = LSget('nio.notes.engine', ''), saved = list.some((e) => e.id === saved0) ? saved0 : auto, groups = [...new Set(list.map((e) => e.group))];
705
730
  const hasText = !!ed.n.text.trim() || !!ed.n.items?.length;
706
- p.innerHTML = `<label class="nt-pad small"><span class="muted">Model</span><select id="aiEng" class="nt-sel">${groups.map((g) => `<optgroup label="${esc(g)}">${list.filter((e) => e.group === g).map((e) => `<option value="${esc(e.id)}" ${e.id === saved ? 'selected' : ''}>${esc(e.label)}</option>`).join('')}</optgroup>`).join('')}</select></label>
731
+ p.innerHTML = popGrab(p) + `<label class="nt-pad small"><span class="muted">Model</span><select id="aiEng" class="nt-sel">${groups.map((g) => `<optgroup label="${esc(g)}">${list.filter((e) => e.group === g).map((e) => `<option value="${esc(e.id)}" ${e.id === saved ? 'selected' : ''}>${esc(e.label)}</option>`).join('')}</optgroup>`).join('')}</select></label>
707
732
  <div class="nt-acts-list">${ACTIONS.map(([k, l, d]) => `<button type="button" data-ai="${k}" ${hasText ? '' : 'disabled'}><b>${l}</b><span>${d}</span></button>`).join('')}</div>
708
733
  <form class="nt-lf" id="aiForm"><input type="text" placeholder="O demana-li qualsevol cosa…" maxlength="300" aria-label="Instrucció pròpia" ${hasText ? '' : 'disabled'}><button class="btn ink sm" type="submit" ${hasText ? '' : 'disabled'}>Envia</button></form>
709
734
  <p class="nt-note small muted" id="aiNote"></p>`;
710
735
  const eng = $('#aiEng', p), note = $('#aiNote', p);
711
736
  const upd = () => { const e = list.find((x) => x.id === eng.value); note.textContent = e?.local ? 'Es queda a l’ordinador: cap dada surt de casa.' : 'Aquest text s’envia al servei del model (Anthropic, OpenAI o núvol d’Ollama).'; };
712
737
  eng.onchange = () => { LSset('nio.notes.engine', eng.value); upd(); }; upd();
713
- p.addEventListener('click', (e) => { const x = e.target.closest('[data-ai]'); if (x && !x.disabled) runAi(x.dataset.ai, eng.value); });
738
+ p.addEventListener('click', (e) => { const x = Date.now() - moreDraggedAt < 350 ? null : e.target.closest('[data-ai]'); if (x && !x.disabled) runAi(x.dataset.ai, eng.value); });
714
739
  $('#aiForm', p).onsubmit = (e) => { e.preventDefault(); const v = $('input', p).value.trim(); if (v) runAi('custom', eng.value, v); };
715
740
  }
716
741
  async function runAi(action, engine, prompt) {
package/web/app/style.css CHANGED
@@ -141,6 +141,10 @@ button.chip:hover { border-color: var(--fg); }
141
141
  .chip.usage .ring .rv { stroke: var(--moss); }
142
142
  .chip.usage[data-hi="1"] .ring .rv { stroke: var(--red); }
143
143
  .chip.usage .uw[data-hi="1"] { color: var(--red); font-weight: 600; }
144
+ /* Al mòbil, l'ús va a la barra de dalt (a la barra de xips quedava fora de la pantalla, per la dreta). */
145
+ .chip.usage.mini { height: 32px; padding: 0 10px 0 8px; font-size: 12px; white-space: nowrap; flex: none; cursor: pointer; }
146
+ #mRight { display: flex; align-items: center; gap: 4px; }
147
+ @media (max-width: 820px) { .mode-bar .chip.usage { display: none; } }
144
148
  .chip.beta { height: 20px; padding: 0 7px; font-size: 10px; color: var(--fg-muted); }
145
149
  .pulse { width: 6px; height: 6px; border-radius: 50%; background: currentColor; animation: pulse 1.2s ease-in-out infinite; }
146
150
  @keyframes pulse { 50% { opacity: .3; } }
package/web/sw.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // nio · service worker: obre l'app encara que no hi hagi connexió i mostra els avisos.
2
- const SHELL = 'nio-shell-v59';
3
- const FILES = ['/app', '/app/style.css', '/app/notes.css', '/app/call.css', '/app/app.js', '/app/notes.js', '/app/notes-live.js', '/app/call.js', '/app/demo.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
2
+ const SHELL = 'nio-shell-v61';
3
+ const FILES = ['/app', '/app/style.css', '/app/notes.css', '/app/call.css', '/app/app.js', '/app/notes.js', '/app/notes-live.js', '/app/call.js', '/app/demo.js', '/app/android.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
4
4
 
5
5
  self.addEventListener('install', (e) => {
6
6
  e.waitUntil(caches.open(SHELL).then((c) => c.addAll(FILES)).catch(() => {}));