nio-cat 0.9.11 → 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.11",
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",
@@ -23,10 +23,9 @@ module.exports = {
23
23
  // Que l'agent posi un títol que resumeixi la feina (eina MCP de nio; el hub el desa).
24
24
  args.push('--append-system-prompt', 'Treballes dins una sessió de nio. Quan entenguis la tasca, crida l’eina mcp__nio__sessio_titol amb un títol de 3-7 paraules que la resumeixi, en l’idioma de l’usuari; torna-la a cridar només si la feina canvia de tema.');
25
25
 
26
- if (session.mode === 'tot' && process.getuid?.() === 0) {
27
- ctx.fail('Claude Code no deixa el mode «Via lliure» quan s’executa com a root. Canvia els permisos a «Demana permís» o engega nio amb un usuari normal.');
28
- return { kill() {} };
29
- }
26
+ // Com a root (un VPS), Claude Code no accepta bypassPermissions: «Via lliure» hi fa servir el mode auto,
27
+ // que també treballa sol però fa revisar cada acció a un classificador (el mateix que el Claude Code remot).
28
+ const root = process.getuid?.() === 0;
30
29
 
31
30
  const env = {};
32
31
  const gate = (extra = {}) => {
@@ -38,7 +37,7 @@ module.exports = {
38
37
  if (session.mode === 'llegir') {
39
38
  args.push('--permission-mode', 'plan');
40
39
  } else if (session.mode === 'tot') {
41
- args.push('--permission-mode', 'bypassPermissions');
40
+ args.push('--permission-mode', root ? 'auto' : 'bypassPermissions');
42
41
  // Amb el control del navegador o l'escriptori actiu, les ordres que hi actuen sempre es pregunten.
43
42
  if (control) gate({ NIO_GATE: 'tot' });
44
43
  } else {
package/src/notes.js CHANGED
@@ -195,9 +195,11 @@ class Notes {
195
195
  async liveTranscribe(n, sg) {
196
196
  if (!this.notes.includes(n)) return;
197
197
  try {
198
- const { text } = await voice.transcribe(this.cfg, fs.readFileSync(this.liveFile(n, sg)), sg.mime, n.live.lang);
199
- const t = String(text || '').replace(/\s+/g, ' ').trim();
200
- sg.text = HALLUCINATION.test(t) ? '' : t; sg.state = 'ok'; sg.error = '';
198
+ // El final del tros anterior fa de context: Whisper hi enganxa millor les frases i hi escriu igual els noms.
199
+ const prev = n.live.segs.filter((x) => x.seq < sg.seq && x.text).pop()?.text || '';
200
+ const { text } = await voice.transcribe(this.cfg, fs.readFileSync(this.liveFile(n, sg)), sg.mime, n.live.lang, { prompt: prev.slice(-200) });
201
+ const t = unloop(String(text || '').replace(/\s+/g, ' ').trim());
202
+ sg.text = HALLUCINATION.test(t) || (prev && t === prev) ? '' : t; sg.state = 'ok'; sg.error = '';
201
203
  } catch (e) {
202
204
  sg.state = 'error'; sg.error = String(e.message || e).slice(0, 300);
203
205
  }
@@ -218,8 +220,8 @@ class Notes {
218
220
  const pick = await this.pickEngine(L.engine);
219
221
  if (!pick) return; // sense model: al final es deixa la transcripció
220
222
  const part = { from: ready[0].seq, to: ready[ready.length - 1].seq, text: '', error: '' };
221
- if (text.trim().length > 40) {
222
- try { part.text = (await this.ask(pick, LIVE_TASKS.part[L.kind], text, false)).text.trim(); }
223
+ if (words(text) >= MIN_WORDS) {
224
+ try { part.text = cleanOut((await this.ask(pick, LIVE_TASKS.part[L.kind], text, false)).text); }
223
225
  catch (e) { part.error = String(e.message || e).slice(0, 200); }
224
226
  }
225
227
  if (!this.notes.includes(n)) return;
@@ -260,6 +262,11 @@ class Notes {
260
262
  await this.liveParts(n, true);
261
263
  const transcript = L.segs.map((x) => x.text).filter(Boolean).join(' ').trim();
262
264
  if (!transcript) throw new Error('No s’ha transcrit res: no hi ha res a resumir.');
265
+ // Amb quatre paraules, el model no té res a resumir i acaba resumint les seves instruccions.
266
+ if (words(transcript) < MIN_WORDS) {
267
+ if (!n.text.trim()) n.text = transcript;
268
+ throw new Error('Hi ha massa poca transcripció per fer-ne un resum: l’he deixada a la nota. Comprova que el micròfon capti bé la veu.');
269
+ }
263
270
  const pick = await this.pickEngine(L.engine);
264
271
  if (!pick) {
265
272
  if (!n.text.trim()) n.text = transcript;
@@ -274,11 +281,19 @@ class Notes {
274
281
  for (const b of blocks) { const g = groups[groups.length - 1]; if (g && g.join('\n\n').length + b.length < max * 0.8) g.push(b); else groups.push([b]); }
275
282
  if (groups.length === blocks.length) groups.splice(0, groups.length, ...Array.from({ length: Math.ceil(blocks.length / 2) }, (_, i) => blocks.slice(i * 2, i * 2 + 2)));
276
283
  blocks = [];
277
- for (const g of groups) blocks.push((await this.ask(pick, LIVE_TASKS.condense, g.join('\n\n'), false)).text.trim());
284
+ for (const g of groups) blocks.push(cleanOut((await this.ask(pick, LIVE_TASKS.condense, g.join('\n\n'), false)).text) || g.join('\n\n').slice(0, max / 2));
285
+ }
286
+ let r = await this.ask(pick, LIVE_TASKS.final[L.kind], blocks.join('\n\n').slice(0, max), true);
287
+ if (echoes(r.text) || echoes(r.title)) r = await this.ask(pick, LIVE_TASKS.final[L.kind], blocks.join('\n\n').slice(0, max), true); // un segon intent
288
+ if (echoes(r.text) || echoes(r.title)) {
289
+ if (!n.text.trim()) n.text = transcript;
290
+ throw new Error('El model ha resumit les instruccions en lloc de la classe. He deixat la transcripció a la nota: torna-ho a provar amb un model més gran.');
278
291
  }
279
- const r = await this.ask(pick, LIVE_TASKS.final[L.kind], blocks.join('\n\n').slice(0, max), true);
280
292
  const text = str(r.text, 200000).trim();
281
- if (!text) throw new Error('El model no ha tornat cap resum. Torna-ho a provar o tria’n un altre.');
293
+ if (!text) {
294
+ if (!n.text.trim()) n.text = transcript;
295
+ throw new Error('El model diu que la transcripció no té prou contingut per resumir-la. L’he deixada a la nota.');
296
+ }
282
297
  if (!this.notes.includes(n)) return;
283
298
  const title = str(r.title, 120).trim();
284
299
  if (title && n.title === L.autoTitle) { n.title = `${title}`; L.autoTitle = n.title; }
@@ -300,7 +315,7 @@ class Notes {
300
315
 
301
316
  async ask(pick, task, body, json) {
302
317
  const local = pick.id.startsWith('ollama:'), wrap = !json && local;
303
- const user = `--- TRANSCRIPCIÓ ---\n${body}\n--- FI ---\n\n${task}${wrap ? '\n\nTorna només JSON amb aquest format: {"text":"…"}. El text pot tenir salts de línia.' : ''}`;
318
+ const user = `--- TRANSCRIPCIÓ ---\n${body}\n--- FI ---\n\n${task}\n\nFes-ho només amb el que es diu entre «--- TRANSCRIPCIÓ ---» i «--- FI ---». No parlis d’aquestes instruccions ni de tu mateix. Si la transcripció no té prou contingut, torna el text buit.${wrap ? '\n\nTorna només JSON amb aquest format: {"text":"…"}. El text pot tenir salts de línia.' : ''}`;
304
319
  const raw = await runModel(this.cfg, pick.id, LIVE_SYSTEM, user, json || wrap, { numCtx: local ? 8192 : 0 });
305
320
  return json ? parseJson(raw) : wrap ? { text: String(parseJson(raw).text ?? '') } : { text: raw };
306
321
  }
@@ -419,6 +434,18 @@ const LIVE_LANGS = ['ca', 'es', 'en'];
419
434
  const PART_CHARS = 3500; // ≈ 4-5 minuts de veu per tros d'apunts
420
435
  // Whisper s'inventa aquestes frases quan sent silenci o soroll.
421
436
  const HALLUCINATION = /^[\s.…]*$|^(gràcies( per (veure|mirar|escoltar)[^.!]*)?|subtítols[^.!]*|amara\.org[^.!]*|gracias( por (ver|mirar)[^.!]*)?|subtítulos[^.!]*|thank you( for watching)?|thanks for watching)[.!…\s]*$/i;
437
+ const MIN_WORDS = 25; // per sota, no hi ha res a apuntar ni a resumir
438
+ const words = (t) => (String(t || '').match(/[\p{L}\p{N}]+/gu) || []).length;
439
+ // Whisper de vegades es queda encallat repetint la mateixa frase: se'n deixa una.
440
+ function unloop(t) {
441
+ let out = t;
442
+ for (let n = 1; n <= 12; n++) out = out.replace(new RegExp(`((?:\\S+\\s+){${n - 1}}\\S+)(?:\\s+\\1(?![\\p{L}\\p{N}])){3,}`, 'gu'), '$1');
443
+ return out;
444
+ }
445
+ // La resposta parla de les instruccions (el «system prompt») en lloc de la transcripció.
446
+ const ECHO = /assistent de notes|transcripci[óo] autom[àa]tica|no t[’']inventis|no facis servir cap eina|---\s*(TRANSCRIPCI[ÓO]|FI)\s*---|torna nom[ée]s json|en l[’']idioma de la transcripci[óo]|les meves instruccions|system prompt/i;
447
+ const echoes = (t) => ECHO.test(String(t || ''));
448
+ const cleanOut = (t) => { const v = String(t || '').trim(); return echoes(v) ? '' : v; };
422
449
  const LIVE_SYSTEM = 'Ets l’assistent de notes de nio. Treballes amb la transcripció automàtica d’una classe o d’una reunió: pot tenir paraules mal transcrites, que pots corregir quan el context ho deixa clar. No t’inventis res que no s’hi digui. Respon en l’idioma de la transcripció (si dubtes, en català). No facis servir cap eina.';
423
450
  const LIVE_TASKS = {
424
451
  part: {
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
  }
package/src/voice.js CHANGED
@@ -103,13 +103,13 @@ function status(cfg) {
103
103
  };
104
104
  }
105
105
 
106
- async function transcribe(cfg, audio, mime, lang) {
106
+ async function transcribe(cfg, audio, mime, lang, opts = {}) {
107
107
  const p = pick(cfg, 'stt');
108
108
  lang = lang || cfgOf(cfg).lang || 'ca';
109
109
  const l = lang === 'auto' ? '' : lang; // «auto»: que el proveïdor ho endevini
110
110
  if (!p) throw httpErr(400, 'Aquest ordinador encara no té veu. Activa Whisper (gratis) o posa una clau a Ajustos → Veu.');
111
111
  if (!audio?.length) throw httpErr(400, 'No hi ha àudio');
112
- if (p === 'whisper') return { text: await whisper.transcribe(cfg, audio, mime, lang), provider: p };
112
+ if (p === 'whisper') return { text: await whisper.transcribe(cfg, audio, mime, lang, opts), provider: p };
113
113
  return { text: (await PROVIDERS[p].stt(cfg.voice.keys[p], audio, mime, l)).trim(), provider: p };
114
114
  }
115
115
 
package/src/whisper.js CHANGED
@@ -120,7 +120,7 @@ function ext(mime) {
120
120
  return /ogg/.test(mime || '') ? 'ogg' : /mp4|m4a|aac/.test(mime || '') ? 'm4a' : /mpeg|mp3/.test(mime || '') ? 'mp3' : /wav/.test(mime || '') ? 'wav' : 'webm';
121
121
  }
122
122
 
123
- async function transcribe(cfg, audio, mime, lang = 'ca') {
123
+ async function transcribe(cfg, audio, mime, lang = 'ca', { prompt = '' } = {}) {
124
124
  const e = engine();
125
125
  if (!e) throw httpErr(400, `Falta Whisper en aquest ordinador. Instal·la’l: ${installHint()}`);
126
126
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'nio-veu-'));
@@ -128,7 +128,7 @@ async function transcribe(cfg, audio, mime, lang = 'ca') {
128
128
  const input = path.join(tmp, `audio.${ext(mime)}`);
129
129
  fs.writeFileSync(input, audio);
130
130
  if (e === 'python') {
131
- await run(pyBin(), [input, '--model', 'turbo', ...(lang === 'auto' ? [] : ['--language', lang]), '--output_format', 'txt', '--output_dir', tmp, '--fp16', 'False', '--verbose', 'False']);
131
+ await run(pyBin(), [input, '--model', 'turbo', ...(lang === 'auto' ? [] : ['--language', lang]), '--output_format', 'txt', '--output_dir', tmp, '--fp16', 'False', '--verbose', 'False', ...(prompt ? ['--initial_prompt', prompt] : [])]);
132
132
  return fs.readFileSync(path.join(tmp, 'audio.txt'), 'utf8').trim();
133
133
  }
134
134
  if (!fs.existsSync(modelPath(cfg))) {
@@ -138,7 +138,7 @@ async function transcribe(cfg, audio, mime, lang = 'ca') {
138
138
  const wav = path.join(tmp, 'audio.wav');
139
139
  await run(which('ffmpeg'), ['-nostdin', '-loglevel', 'error', '-i', input, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', wav], 60000);
140
140
  const threads = String(Math.max(1, Math.min(8, os.cpus().length - 1)));
141
- const out = await run(cppBin(), ['-m', modelPath(cfg), '-f', wav, '-l', lang, '-t', threads, '-nt', '-np']);
141
+ const out = await run(cppBin(), ['-m', modelPath(cfg), '-f', wav, '-l', lang, '-t', threads, '-nt', '-np', ...(prompt ? ['--prompt', prompt] : [])]);
142
142
  return out.split('\n').map((l) => l.trim()).filter((l) => l && !/^\[(BLANK_AUDIO|Música|Music)\]$/i.test(l)).join(' ').trim();
143
143
  } finally {
144
144
  fs.rm(tmp, { recursive: true, force: true }, () => {});
@@ -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,14 +619,14 @@ 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?.(() => {});
583
626
  }
584
627
  let notesCtl = null, callCtl = null;
585
628
  const getCall = () => (callCtl ||= createCall({ api, esc, toast, bot, machine, isDemo, noMachine, voiceSheet, agentName: (a) => AGENT_NAMES[a] || a, sessions: () => [...state.sessions.values()], approvals: () => [...state.approvals.keys()], refresh: () => {} }));
586
- const getNotes = () => (notesCtl ||= createNotes({ api, nodeAudio, toast, esc, machine, voiceOn, voiceSheet, isDemo, isPhone, openSheet, closeSheet, mobileQrSheet }));
629
+ const getNotes = () => (notesCtl ||= createNotes({ api, nodeAudio, toast, esc, machine, voiceOn, voiceSheet, isDemo, isPhone, openSheet, closeSheet, mobileQrSheet, nodeVersion: () => state.info?.version || '', nodeAtLeast: (v) => !state.info || verAtLeast(state.info.version, v), installCmd: INSTALL_CMD }));
587
630
  function render() {
588
631
  const r = state.route, view = $('#view'), board = r.name === 'pissarra';
589
632
  if (r.name !== 'sessions') manage = null;
@@ -738,10 +781,12 @@ function noMachine(text) {
738
781
  function viewSettings() {
739
782
  const head = '<div class="top"><div><h1>Ajustos</h1><p>Avisos al mòbil, veu i Telegram. Es configuren per a cada ordinador.</p></div></div>';
740
783
  if (!machine()) return `${head}<div class="stack-lg" style="max-width:640px">${settingsOff()}</div>`;
741
- 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>` : '';
784
+ const avisos = window.NioAndroid && machine() ? '<section class="panel"><header><h2>Avisos al mòbil</h2></header><div class="body stack"><p class="small">Dins l’app d’Android, els avisos encara no hi són. Per rebre’ls, connecta Telegram aquí sota o activa’ls obrint nio.cat al navegador.</p></div></section>' : 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>` : '';
742
785
  if (machine() && pushOn === null) refreshPush();
743
786
  if (machine() && tgState === undefined) loadTelegram();
744
- return `${head}<div class="stack-lg cols">${avisos}${voicePanel()}<div id="tgPanel">${telegramPanel()}</div></div>`;
787
+ // App d'Android: només es proposa des del navegador d'un mòbil Android (dins l'app ja no cal).
788
+ const android = /Android/i.test(navigator.userAgent) && !window.NioAndroid ? `<section class="panel"><header><h2>App d’Android</h2></header><div class="body stack"><p class="small">nio a pantalla completa i, sobretot, <b>classes i reunions que es continuen gravant amb la pantalla bloquejada</b>. En instal·lar-la, el navegador et demanarà permís per instal·lar apps d’aquesta font. Dins l’app, torna a connectar l’ordinador amb el QR.</p><div class="row"><a class="btn ink grow" href="/nio-android.apk" download="nio.apk">Baixa l’app (APK)</a></div></div></section>` : '';
789
+ return `${head}<div class="stack-lg cols">${android}${avisos}${voicePanel()}<div id="tgPanel">${telegramPanel()}</div></div>`;
745
790
  }
746
791
  // Ajustos sense ordinador: un sol avís a dalt i les seccions de mostra, desactivades.
747
792
  function settingsOff() {
@@ -806,15 +851,27 @@ async function sshAdd(target, onStep, via) {
806
851
  if (!last || last.error) throw new Error(last?.error || 'S’ha tallat la connexió');
807
852
  return last;
808
853
  }
854
+ // Lector de QR: BarcodeDetector si el navegador en té; si no (Firefox, l'app d'Android), jsQR, que llegeix la imatge en JavaScript.
855
+ let jsQRload = null;
856
+ const loadJsQR = () => (jsQRload ||= new Promise((ok, ko) => { const s = document.createElement('script'); s.src = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js'; s.onload = () => (window.jsQR ? ok(window.jsQR) : ko(new Error('jsQR'))); s.onerror = () => { jsQRload = null; ko(new Error('No he pogut carregar el lector de QR')); }; document.head.appendChild(s); }));
809
857
  async function startScan(video, onText) {
810
- const det = new BarcodeDetector({ formats: ['qr_code'] });
858
+ let detect;
859
+ if ('BarcodeDetector' in window) { const det = new BarcodeDetector({ formats: ['qr_code'] }); detect = async () => (await det.detect(video))[0]?.rawValue; }
860
+ else {
861
+ const jsQR = await loadJsQR(), cv = document.createElement('canvas'), g = cv.getContext('2d', { willReadFrequently: true });
862
+ detect = async () => {
863
+ const w = Math.min(640, video.videoWidth), h = Math.round(video.videoHeight * (w / video.videoWidth)); if (!w || !h) return null;
864
+ cv.width = w; cv.height = h; g.drawImage(video, 0, 0, w, h);
865
+ return jsQR(g.getImageData(0, 0, w, h).data, w, h, { inversionAttempts: 'dontInvert' })?.data;
866
+ };
867
+ }
811
868
  const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
812
869
  video.srcObject = stream; await video.play(); let alive = true;
813
- (async () => { while (alive) { try { const r = await det.detect(video); if (r[0]) onText(r[0].rawValue); } catch { /* quadre sense QR */ } await sleep(250); } })();
870
+ (async () => { while (alive) { try { const t = await detect(); if (t) onText(t); } catch { /* quadre sense QR */ } await sleep(250); } })();
814
871
  return { stop() { alive = false; stream.getTracks().forEach((t) => t.stop()); } };
815
872
  }
816
873
  function addComputerSheet(tab, opts = {}) {
817
- const phone = isPhone(), canScan = 'BarcodeDetector' in window && !!navigator.mediaDevices?.getUserMedia;
874
+ const phone = isPhone(), canScan = !!navigator.mediaDevices?.getUserMedia;
818
875
  const TABS = phone ? [['code', 'Codi o QR', ICON.scan], ['vps', 'Servidor', ICON.server]] : [['local', 'Aquest ordinador', ICON.machine], ['vps', 'Servidor', ICON.server], ['code', 'Tinc un codi', ICON.scan]];
819
876
  let cur = TABS.some((t) => t[0] === tab) ? tab : TABS[0][0], poll = 0, scan = null, shown = '', hosts = [];
820
877
  const intro = (t, d) => `<div class="add-intro"><h3>${t}</h3>${d ? `<p>${d}</p>` : ''}</div>`;
@@ -891,6 +948,7 @@ function addComputerSheet(tab, opts = {}) {
891
948
  }
892
949
  function machinesSheet() {
893
950
  const ms = store.machines;
951
+ refreshSnaps();
894
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>'}
895
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)); });
896
954
  }
@@ -1313,12 +1371,14 @@ function renderSession() {
1313
1371
  function sessionMood(s) { return !s ? 'idle' : s.state === 'running' ? 'work' : s.state === 'waiting' || s.pending ? 'peek' : s.state === 'error' ? 'sleep' : 'idle'; }
1314
1372
  // Ús de la subscripció de l'agent (5 h i setmana), com a esfis. Només si l'agent l'ha informat.
1315
1373
  const hhmm = (t) => new Date(t * 1000).toLocaleString('ca', { weekday: t * 1000 - Date.now() > 864e5 ? 'short' : undefined, hour: '2-digit', minute: '2-digit' });
1316
- 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) {
1317
1376
  const u = state.usage?.[agent]; if (!u?.windows?.length) return '';
1318
1377
  const tip = u.windows.map((w) => `${w.label}: ${Math.round(w.used * 100)} %${w.resetsAt ? ` · es reinicia ${hhmm(w.resetsAt)}` : ''}`).join('\n');
1319
1378
  // Etiquetes curtes: «5h», «7d». La icona és un anell que s'omple amb la finestra més plena.
1320
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);
1321
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>`; }
1322
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>`;
1323
1383
  }
1324
1384
  function renderSessionHead() {
@@ -1663,8 +1723,7 @@ $('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
1663
1723
 
1664
1724
  // Al mòbil, els fulls es tanquen lliscant cap avall, com els de l'iPhone: el full segueix el dit
1665
1725
  // i, si baixa prou o es llença, marxa; si no, torna a pujar. Si el contingut té scroll, primer s'ha d'arribar a dalt.
1666
- (() => {
1667
- const d = $('#sheet');
1726
+ function dragToClose(d, close) {
1668
1727
  let g = null;
1669
1728
  const scrollerOf = (t) => { for (let el = t; el && el !== d.parentElement; el = el.parentElement) { if (el.scrollHeight > el.clientHeight + 1 && /auto|scroll/.test(getComputedStyle(el).overflowY)) return el; if (el === d) break; } return null; };
1670
1729
  const reset = () => { d.style.transition = ''; d.style.transform = ''; };
@@ -1692,21 +1751,27 @@ $('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
1692
1751
  const x = g; g = null; if (!x || x.lock !== 'y') return;
1693
1752
  const [t0, y0] = x.pts[0], [t1, y1] = x.pts[x.pts.length - 1], v = (y1 - y0) / Math.max(1, t1 - t0);
1694
1753
  if (e.type === 'touchend' && (x.dy > Math.min(160, d.offsetHeight * 0.28) || (v > 0.5 && x.dy > 20))) {
1695
- if (reduce) { reset(); closeSheet(); return; }
1754
+ if (reduce) { reset(); close(); return; }
1696
1755
  d.style.transition = 'transform .2s cubic-bezier(.4,0,1,1)'; d.style.transform = `translateY(${d.offsetHeight + 20}px)`;
1697
- setTimeout(() => { closeSheet(); requestAnimationFrame(reset); }, 200);
1756
+ setTimeout(() => { close(); requestAnimationFrame(reset); }, 200);
1698
1757
  } else { d.style.transition = 'transform .3s cubic-bezier(.22,1,.36,1)'; d.style.transform = ''; setTimeout(reset, 300); }
1699
1758
  };
1700
1759
  d.addEventListener('touchend', end, { passive: true });
1701
1760
  d.addEventListener('touchcancel', end, { passive: true });
1702
1761
  d.addEventListener('close', reset);
1703
- })();
1762
+ }
1763
+ dragToClose($('#sheet'), () => closeSheet());
1764
+ dragToClose($('#palette'), () => $('#palette').close());
1704
1765
 
1705
- // Claude Code no deixa «Via lliure» quan nio corre com a root (típic en un VPS).
1706
- const modeBlocked = (agentId, mode) => agentId === 'claude' && mode === 'tot' && state.info?.user === 'root';
1766
+ // Claude Code no deixa «Via lliure» (bypassPermissions) quan nio corre com a root (típic en un VPS).
1767
+ // Des de nio-cat 0.9.12, com a root fa servir el mode auto de Claude Code; només les versions anteriors el bloquegen.
1768
+ const verAtLeast = (v, min) => { const a = String(v || '').split('.').map(Number), b = min.split('.').map(Number); for (let i = 0; i < 3; i++) { if ((a[i] || 0) !== b[i]) return (a[i] || 0) > b[i]; } return true; };
1769
+ const asRoot = (agentId) => agentId === 'claude' && state.info?.user === 'root';
1770
+ const modeBlocked = (agentId, mode) => mode === 'tot' && asRoot(agentId) && !verAtLeast(state.info?.version, '0.9.12');
1707
1771
  function modeHelp(agentId, mode) {
1708
1772
  const a = state.info?.agents?.find((x) => x.id === agentId);
1709
- if (modeBlocked(agentId, mode)) return 'No disponible: Claude Code no ho permet quan s’executa com a root. Fes servir «Demana permís» o engega nio amb un usuari normal.';
1773
+ if (modeBlocked(agentId, mode)) return 'No disponible en aquesta versió de nio quan s’executa com a root. Actualitza nio (curl -fsSL https://www.nio.cat/install.sh | sh) o fes servir «Demana permís».';
1774
+ if (mode === 'tot' && asRoot(agentId)) return 'Treballa sense preguntar amb el mode auto de Claude Code (com a root no n’hi ha d’altre): un classificador revisa cada acció i atura les arriscades.';
1710
1775
  if (mode === 'llegir') return 'Llegeix el projecte i et proposa canvis, però no toca cap fitxer.';
1711
1776
  if (mode === 'tot') return 'Treballa sense preguntar. Fes-lo servir només en carpetes on confiïs plenament.';
1712
1777
  if (a?.approvals) return 'Llegeix sol. Abans d’editar un fitxer o d’executar una ordre, et demana permís aquí.';
@@ -2532,9 +2597,16 @@ document.addEventListener('click', async (e) => {
2532
2597
 
2533
2598
  /* ---------------------------------------------------------------- Telegram (bot propi, configurat a l'ordinador) */
2534
2599
  let tgState;
2600
+ let tgOthers = []; // bots dels altres ordinadors: [{ name, bot, allowed }]
2535
2601
  async function loadTelegram() {
2536
2602
  try { tgState = await api('/api/telegram'); } catch { tgState = null; }
2537
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();
2538
2610
  }
2539
2611
  function telegramPanel() {
2540
2612
  if (!machine()) return '';
@@ -2543,9 +2615,11 @@ function telegramPanel() {
2543
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>';
2544
2616
  if (!t.configured) return `<section class="panel"><header><h2>Telegram</h2></header><div class="body stack">
2545
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>
2546
- <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>
2547
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>`;
2548
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>' : ''}
2549
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>` : ''}
2550
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>
2551
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>
@@ -2556,7 +2630,10 @@ function telegramPanel() {
2556
2630
  }
2557
2631
  document.addEventListener('submit', async (e) => {
2558
2632
  if (e.target.id !== 'tgForm') return; e.preventDefault();
2559
- 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);
2560
2637
  $('#tgPanel').innerHTML = telegramPanel();
2561
2638
  });
2562
2639
  document.addEventListener('change', async (e) => {
@@ -2771,6 +2848,7 @@ document.addEventListener('click', async (e) => {
2771
2848
  case 'model': { const s = state.sessions.get(state.route.id); if (s) modelSheet(s); return; }
2772
2849
  case 'mode': { const s = state.sessions.get(state.route.id); if (s) modeSheet(s); return; }
2773
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; }
2774
2852
  case 'refresh-agents': try { state.info = await api('/api/info?refresh=1'); render(); toast('Agents actualitzats'); } catch (err) { toast(err.message); } return;
2775
2853
  case 'push': return enablePush();
2776
2854
  case 'push-off': return disablePush();
@@ -3041,6 +3119,20 @@ if ('serviceWorker' in navigator) {
3041
3119
  navigator.serviceWorker.register('/sw.js').catch(() => {});
3042
3120
  navigator.serviceWorker.addEventListener('message', (e) => { if (e.data?.type === 'open-session' && e.data.id) go(`#/s/${e.data.id}`); });
3043
3121
  }
3122
+ // L'app d'Android (i la de la pantalla d'inici) es queda oberta dies sense recarregar: en tornar-hi, si hi ha una versió
3123
+ // nova de la web (sw.js canvia a cada canvi de l'app), es recarrega, però només si no estàs fent res a mitges.
3124
+ (() => {
3125
+ const ver = async () => { try { return (await (await fetch('/sw.js', { cache: 'no-store' })).text()).match(/nio-shell-v\d+/)?.[0] || null; } catch { return null; } };
3126
+ const busy = () => document.querySelector('dialog[open], .nt-ov, .call-ov, .rd, .lv-pill, .lv-rec') || [...document.querySelectorAll('textarea, input[type=text], input:not([type])')].some((t) => t.value.trim());
3127
+ let mine = null, hiddenAt = 0;
3128
+ ver().then((v) => { mine = v; });
3129
+ document.addEventListener('visibilitychange', async () => {
3130
+ if (document.hidden) { hiddenAt = Date.now(); return; }
3131
+ if (!mine || Date.now() - hiddenAt < 60000) return;
3132
+ const now = await ver();
3133
+ if (now && now !== mine && !document.hidden && !busy()) location.reload();
3134
+ });
3135
+ })();
3044
3136
  addEventListener('hashchange', route);
3045
3137
  // El mòbil congela la pàgina en segon pla i el flux queda mort sense cap error: l'ordinador envia un ping cada 15 s,
3046
3138
  // així que si fa més de 40 s que no arriba res (o en tornar a l'app, més de 20 s), es torna a connectar sol.
@@ -3067,17 +3159,18 @@ if (['127.0.0.1', 'localhost', '[::1]'].includes(location.hostname)) {
3067
3159
  }
3068
3160
  setNav(false);
3069
3161
  if (!checkPairHash()) state.route = parseRoute();
3070
- if (machine()) connect();
3162
+ if (machine()) { restoreSnap(machine()); connect(); }
3071
3163
  if (state.route.name === 'session') openSession(state.route.id); else render();
3072
3164
  if (T.on) tourGo(T.i); else if (!T.seen && !machine()) setTimeout(tourStart, 700);
3073
3165
 
3074
3166
  /* ---------------------------------------------------------------- lliscar entre pàgines (mòbil) */
3075
3167
  (() => {
3076
- const PAGES = ['sessions', 'approvals', 'ordinadors'];
3168
+ // Les pàgines de la barra de baix, en el mateix ordre («Pantalla» i «Més» no són pàgines: obren un full).
3169
+ const PAGES = ['sessions', 'notes', 'ordinadors'];
3077
3170
  const view = $('#view');
3078
3171
  let g = null, timer = 0;
3079
3172
  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; };
3080
- 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);
3173
+ 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], .nt-card') || scrollsX(t); // a Notes, lliscar una targeta l'arxiva
3081
3174
  const target = (dir) => PAGES[PAGES.indexOf(state.route.name) + dir];
3082
3175
  const put = (x, o) => { view.style.setProperty('--sx', x + 'px'); view.style.setProperty('--so', String(o)); };
3083
3176
  const clear = () => { view.classList.remove('swiping', 'swipe-anim'); view.style.removeProperty('--sx'); view.style.removeProperty('--so'); };
@@ -32,6 +32,8 @@
32
32
  <link rel="preconnect" href="https://fonts.googleapis.com">
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
+ <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>
35
37
  <link rel="stylesheet" href="/bot.css">
36
38
  <link rel="stylesheet" href="/board.css">
37
39
  <link rel="stylesheet" href="/app/style.css">
@@ -6,6 +6,9 @@
6
6
  const SEG_MS = Number(globalThis.NIO_LIVE_SEG_MS) || 20000; // durada de cada tros: prou curt per veure-ho gairebé en directe, prou llarg perquè Whisper tingui context
7
7
  const SILENCE = 0.003; // per sota d'aquest volum (RMS) tot el tros, no s'envia: Whisper s'inventa frases en el silenci
8
8
  const KINDS = { classe: 'Classe', reunio: 'Reunió' };
9
+ const LIVE_MIN = '0.9.11', OLD = /ruta no existeix|Error 404/i; // un nio antic, sense classes ni reunions
10
+ // Dins l'app d'Android, grava un servei natiu: continua amb la pantalla bloquejada (android/LiveService.java).
11
+ const NA = typeof window !== 'undefined' && window.NioAndroid?.liveStart ? window.NioAndroid : null;
9
12
  const LANGS = [['ca', 'Català'], ['es', 'Castellà'], ['en', 'Anglès']];
10
13
  const STATES = { rec: 'Gravant', closing: 'Acabant de transcriure…', summarizing: 'Fent el resum…', done: 'Resum fet', error: 'No s’ha pogut resumir' };
11
14
  const LI = {
@@ -44,11 +47,28 @@ export function createLive({ ctx, api, esc, toast, svg, getNote, setNote, loadEn
44
47
  }
45
48
 
46
49
  // ---------------------------------------------------------------- començar
50
+ // L’ordinador té un nio d’abans de les classes: cal actualitzar-lo allà (la web ja és nova).
51
+ function oldSheet() {
52
+ const v = ctx.nodeVersion(), cmd = ctx.installCmd;
53
+ ctx.openSheet(`<h2>Cal actualitzar nio</h2>
54
+ <div class="stack">
55
+ <p>${v ? `Aquest ordinador té nio ${esc(v)}` : 'Aquest ordinador té un nio antic'} i «Classe o reunió» necessita la ${LIVE_MIN} o més nova.</p>
56
+ <p class="small muted">Obre un terminal a l’ordinador i executa:</p>
57
+ <pre class="mono small" style="white-space:pre-wrap;user-select:all">${esc(cmd)}</pre>
58
+ <p class="small muted">Quan acabi, torna aquí i prova-ho de nou.</p>
59
+ <div class="row sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button><button class="btn ink" type="button" data-copy>Copia l’ordre</button></div>
60
+ </div>`, (sh) => {
61
+ sh.querySelector('[data-close]').onclick = ctx.closeSheet;
62
+ sh.querySelector('[data-copy]').onclick = () => navigator.clipboard?.writeText(cmd).then(() => toast('Ordre copiada'), () => toast('No l’he pogut copiar'));
63
+ });
64
+ }
65
+
47
66
  async function startSheet() {
48
67
  if (ctx.isDemo()) return toast('A la demo no hi ha micròfon. Amb nio instal·lat, grava una classe i Whisper la transcriu a l’ordinador.', 5000);
68
+ if (!ctx.nodeAtLeast(LIVE_MIN)) return oldSheet();
49
69
  if (!ctx.voiceOn()) { toast('Cal activar Whisper per transcriure (gratis, a l’ordinador).', 3500); return ctx.voiceSheet(); }
50
70
  if (rec) return open(rec.id);
51
- if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return toast('Aquest navegador no pot gravar àudio.');
71
+ if (!NA && (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder)) return toast('Aquest navegador no pot gravar àudio.');
52
72
  const kind0 = LSget('nio.live.kind', 'classe'), lang0 = LANGS.some(([k]) => k === LSget('nio.live.lang', 'ca')) ? LSget('nio.live.lang', 'ca') : 'ca';
53
73
  ctx.openSheet(`<h2>Classe o reunió</h2>
54
74
  <div class="stack lv-start">
@@ -56,7 +76,7 @@ export function createLive({ ctx, api, esc, toast, svg, getNote, setNote, loadEn
56
76
  <label class="f">Idioma<select id="lvLang">${LANGS.map(([k, l]) => `<option value="${k}" ${k === lang0 ? 'selected' : ''}>${l}</option>`).join('')}</select></label>
57
77
  <label class="f">Model per als apunts i el resum<select id="lvEng"><option>Mirant quins models hi ha…</option></select></label>
58
78
  <p class="small muted" id="lvWhat"></p>
59
- <p class="small lv-warn">Deixa nio obert i la pantalla encesa: si el mòbil es bloqueja, el navegador atura el micròfon.</p>
79
+ <p class="small lv-warn">${NA ? 'Pots bloquejar el mòbil: l’app continua gravant i ho envia a l’ordinador.' : 'Deixa nio obert i la pantalla encesa: si el mòbil es bloqueja, el navegador atura el micròfon. Amb l’app d’Android, no cal.'}</p>
60
80
  <div class="row sheet-foot"><button class="btn ghost" type="button" data-close>Cancel·la</button><button class="btn ink" type="button" data-go>${svg('<circle cx="12" cy="12" r="6" fill="currentColor" stroke="none"/>')}Comença a gravar</button></div>
61
81
  </div>`, async (sh) => {
62
82
  let kind = kind0;
@@ -75,6 +95,16 @@ export function createLive({ ctx, api, esc, toast, svg, getNote, setNote, loadEn
75
95
  });
76
96
  sh.querySelector('[data-go]').onclick = async (ev) => {
77
97
  const b = ev.currentTarget; b.disabled = true;
98
+ if (NA) {
99
+ const lang = sh.querySelector('#lvLang').value, engine = sel.value || '';
100
+ LSset('nio.live.kind', kind); LSset('nio.live.lang', lang); if (engine) LSset('nio.notes.engine', engine);
101
+ try {
102
+ const n = await api('/api/notes/live', { method: 'POST', body: { kind, lang, engine } });
103
+ setNote(n); ctx.closeSheet(); open(n.id);
104
+ await nativeStart(n);
105
+ } catch (e) { b.disabled = false; if (OLD.test(e.message)) return oldSheet(); toast(e.message, 4500); }
106
+ return;
107
+ }
78
108
  // El micròfon es demana dins del clic: Safari no el deixa demanar més tard.
79
109
  let stream;
80
110
  try { stream = await mic(); } catch { b.disabled = false; return toast('No tinc permís per al micròfon. Activa’l als ajustos del navegador.', 4500); }
@@ -84,7 +114,7 @@ export function createLive({ ctx, api, esc, toast, svg, getNote, setNote, loadEn
84
114
  const n = await api('/api/notes/live', { method: 'POST', body: { kind, lang, engine } });
85
115
  setNote(n); ctx.closeSheet();
86
116
  begin(n, stream); open(n.id);
87
- } catch (e) { stream.getTracks().forEach((t) => t.stop()); b.disabled = false; toast(e.message, 4500); }
117
+ } catch (e) { stream.getTracks().forEach((t) => t.stop()); b.disabled = false; if (OLD.test(e.message)) return oldSheet(); toast(e.message, 4500); }
88
118
  };
89
119
  });
90
120
  }
@@ -105,7 +135,46 @@ export function createLive({ ctx, api, esc, toast, svg, getNote, setNote, loadEn
105
135
  segment(rec);
106
136
  rec.tick = setInterval(() => { drawRec(); drawPill(); }, 500);
107
137
  }
108
- const elapsed = (r) => r.base + (Date.now() - r.t0) / 1000;
138
+ const elapsed = (r) => (r.native ? r.st.elapsed || 0 : r.base + (Date.now() - r.t0) / 1000);
139
+
140
+ // ---------------------------------------------------------------- gravació nativa (app d'Android)
141
+ async function nativeStart(n) {
142
+ const m = ctx.machine(), segs = n.live?.segs || [], last = segs[segs.length - 1];
143
+ const cfg = { url: m.url, token: m.token, id: n.id, seq: last ? last.seq + 1 : 0, base: last ? last.at + last.dur : 0, segMs: SEG_MS, kind: KINDS[n.live?.kind] || 'Classe' };
144
+ let r = NA.liveStart(JSON.stringify(cfg));
145
+ if (r === 'perm') {
146
+ // L'app demana el permís del micròfon i avisa quan l'usuari ha respost.
147
+ r = await new Promise((done) => addEventListener('nio-native', function on(e) { if (!e.detail?.live) return; removeEventListener('nio-native', on); done(e.detail.live === 'started' ? 'ok' : 'denied'); }));
148
+ }
149
+ if (r !== 'ok') { toast(r === 'denied' ? 'Sense permís per al micròfon no puc gravar. Activa’l a Ajustos → Apps → nio → Permisos.' : 'No he pogut començar a gravar.', 5000); draw(); return; }
150
+ nativeWatch(n.id, m.url);
151
+ // HyperOS (Xiaomi) i altres aturen les apps en segon pla: es demana que no ho faci amb nio.
152
+ try { if (NA.batteryOk && !NA.batteryOk() && !LSget('nio.live.battery', false)) { LSset('nio.live.battery', true); toast('Perquè no s’aturi amb la pantalla bloquejada, permet que nio funcioni sense restriccions de bateria.', 6000); NA.batteryHelp(); } } catch { /* res */ }
153
+ }
154
+ function nativeWatch(id, url) {
155
+ if (rec?.native) clearInterval(rec.tick);
156
+ const r = rec = { native: true, id, url, running: true, stopping: false, lost: false, queue: [], st: {} };
157
+ const tick = () => {
158
+ let st = {}; try { st = JSON.parse(NA.liveStatus()); } catch { /* res */ }
159
+ r.st = st; r.lost = !!st.lost; r.stopping = !!st.stopping;
160
+ // Si el túnel ha canviat d'adreça, els trossos pendents van a la nova.
161
+ const m = ctx.machine(); if (m && m.url !== r.url) { r.url = m.url; try { NA.liveTarget(m.url, m.token); } catch { /* res */ } }
162
+ if (!st.active) { clearInterval(r.tick); if (rec === r) { rec = null; pill?.remove(); pill = null; draw(); } return; }
163
+ const bar = view && view.el.querySelector('.lv-meter i'); if (bar) bar.style.transform = `scaleX(${Math.min(1, (st.level || 0) * 1.2).toFixed(3)})`;
164
+ // La vista es redibuixa quan canvia l'estat (o si encara no mostra la gravació d'aquí).
165
+ if (view?.id === id) { const st2 = view.el.querySelector('.lv-st'); if (!st2 || st2.textContent !== nativeText(r)) draw(); }
166
+ drawRec(); drawPill();
167
+ };
168
+ r.tick = setInterval(tick, 500); tick();
169
+ }
170
+ function nativeText(r) {
171
+ const q = r.st.queue || 0;
172
+ return r.stopping ? (q ? `Enviant ${q} ${q === 1 ? 'tros' : 'trossos'}…` : 'Acabant…')
173
+ : r.lost ? 'El micròfon és ocupat (una trucada?). Tornarà a gravar sol quan quedi lliure.'
174
+ : q > 1 ? `Sense connexió amb l’ordinador: ${q} trossos esperant` : 'Grava amb la pantalla bloquejada i transcriu cada 20 s';
175
+ }
176
+ // Si l'app es torna a obrir amb una gravació en marxa, se'n recupera l'estat.
177
+ if (NA) { try { const st = JSON.parse(NA.liveStatus()); if (st.active && st.id) setTimeout(() => nativeWatch(st.id, ctx.machine()?.url), 0); } catch { /* res */ } }
109
178
  function segment(r) {
110
179
  if (!r.running || r.lost) return;
111
180
  if (r.stream.getAudioTracks().every((t) => t.readyState === 'ended')) { r.lost = true; draw(); return; }
@@ -117,8 +186,8 @@ export function createLive({ ctx, api, esc, toast, svg, getNote, setNote, loadEn
117
186
  mr.onstop = () => {
118
187
  clearTimeout(r.timer);
119
188
  const dur = (Date.now() - start) / 1000, blob = new Blob(chunks, { type: mr.mimeType || 'audio/webm' });
120
- if (blob.size > 1500 && r.peak >= SILENCE) { r.queue.push({ seq: r.seq++, at: Math.round(at * 10) / 10, dur: Math.round(dur * 10) / 10, blob }); pump(r); }
121
- else if (dur > 2) r.skipped++;
189
+ if (blob.size > 1500 && r.peak >= SILENCE) { r.queue.push({ seq: r.seq++, at: Math.round(at * 10) / 10, dur: Math.round(dur * 10) / 10, blob }); if (r.quiet) { r.quiet = 0; draw(); } pump(r); }
190
+ else if (dur > 2) { r.skipped++; r.quiet = (r.quiet || 0) + 1; if (r.quiet === 2) draw(); } // trossos en silenci seguits: el micròfon no capta la veu
122
191
  if (r.running && !r.lost) segment(r);
123
192
  else if (!r.running) finish(r);
124
193
  };
@@ -147,6 +216,7 @@ export function createLive({ ctx, api, esc, toast, svg, getNote, setNote, loadEn
147
216
  }
148
217
  function stop() {
149
218
  const r = rec; if (!r) return;
219
+ if (r.native) { NA.liveStop(); r.stopping = true; draw(); return; }
150
220
  r.running = false; r.stopping = true;
151
221
  if (r.mr?.state === 'recording') r.mr.stop(); else finish(r);
152
222
  draw();
@@ -168,6 +238,11 @@ export function createLive({ ctx, api, esc, toast, svg, getNote, setNote, loadEn
168
238
  }
169
239
  // El micròfon s'ha aturat (pantalla bloquejada, trucada…): es continua a la mateixa nota amb un clic.
170
240
  async function resume(id) {
241
+ if (NA) {
242
+ if (rec?.native && rec.id === id) return; // l'app ja hi torna sola quan el micròfon queda lliure
243
+ try { const n = await api(`/api/notes/${id}/live/resume`, { method: 'POST' }); setNote(n); await nativeStart(n); draw(); } catch (e) { toast(e.message, 4500); }
244
+ return;
245
+ }
171
246
  let stream;
172
247
  try { stream = await mic(); } catch { return toast('No tinc permís per al micròfon.', 4000); }
173
248
  if (rec && rec.id === id) {
@@ -275,15 +350,16 @@ export function createLive({ ctx, api, esc, toast, svg, getNote, setNote, loadEn
275
350
  t2?.addEventListener('scroll', () => { if (view) view.follow = t2.scrollHeight - t2.scrollTop - t2.clientHeight < 60; }, { passive: true });
276
351
  }
277
352
  function recBar(n) {
278
- const r = rec, waiting = r.queue.length;
279
- const status = r.stopping ? (waiting ? `Enviant ${waiting} ${waiting === 1 ? 'tros' : 'trossos'}…` : 'Acabant…')
353
+ const r = rec, waiting = r.native ? r.st.queue || 0 : r.queue.length;
354
+ const status = r.native ? nativeText(r) : r.stopping ? (waiting ? `Enviant ${waiting} ${waiting === 1 ? 'tros' : 'trossos'}…` : 'Acabant…')
280
355
  : r.lost ? 'El micròfon s’ha aturat (pantalla bloquejada o una altra app). Prem «Continua».'
281
- : waiting > 1 ? `Sense connexió amb l’ordinador: ${waiting} trossos esperant` : 'Grava i transcriu cada 20 s';
356
+ : waiting > 1 ? `Sense connexió amb l’ordinador: ${waiting} trossos esperant`
357
+ : r.quiet >= 2 ? 'No sento res: apropa el micròfon o tria’n un altre a la icona del navegador' : 'Grava i transcriu cada 20 s';
282
358
  return `<div class="lv-rec ${r.lost ? 'lost' : ''}">
283
359
  <i class="lv-dot ${r.lost || r.stopping ? 'off' : ''}"></i><b class="lv-time">${fmt(elapsed(r))}</b>
284
360
  <span class="lv-meter" aria-hidden="true"><i></i></span>
285
361
  <span class="lv-st">${esc(status)}</span>
286
- ${r.lost && !r.stopping ? `<button class="btn sm" type="button" data-lv="resume">Continua</button>` : ''}
362
+ ${r.lost && !r.stopping && !r.native ? `<button class="btn sm" type="button" data-lv="resume">Continua</button>` : ''}
287
363
  ${r.stopping ? '' : `<button class="btn ink sm" type="button" data-lv="stop">${svg('<rect x="7" y="7" width="10" height="10" rx="2"/>')}Atura i resumeix</button>`}
288
364
  </div>`;
289
365
  }
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); } }
@@ -173,6 +179,8 @@ body:has(.nt-ov) { overflow: hidden; }
173
179
  .nt-ai-f { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; }
174
180
 
175
181
  @media (max-width: 700px) {
182
+ /* Els desplegables de la nota (color, etiqueta, assistent) ocupen l'amplada, amb marge: no se surten pels costats. */
183
+ .nt-pop { left: 10px !important; right: 10px; max-width: none; min-width: 0; }
176
184
  .page:has(.nt) { padding-inline: 14px; }
177
185
  .nt-title h1 { font-size: 26px; } .nt-title p { display: none; }
178
186
  .nt-top { margin-bottom: 12px; } .nt-tools .btn span { display: none; } .nt-tools .btn { padding: 0 11px; }
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; } }
@@ -304,6 +308,15 @@ dialog.sheet[open] { animation: pop .22s var(--ease); }
304
308
  #palette li { display: flex; justify-content: space-between; gap: 12px; padding: 10px 12px; border-radius: 10px; cursor: pointer; font-size: 14px; }
305
309
  #palette li[aria-selected="true"] { background: var(--bg-2); }
306
310
  #palette li span { color: var(--fg-muted); font-size: 12px; }
311
+ /* Al mòbil, la paleta és un full de baix com els altres, i es tanca lliscant cap avall. */
312
+ @media (max-width: 820px) {
313
+ #palette { width: 100%; max-width: none; margin: auto 0 0; border-radius: 20px 20px 0 0; padding-bottom: var(--safe-b); max-height: 82dvh; }
314
+ #palette[open] { display: flex; flex-direction: column; animation: sheet-up .3s var(--ease); }
315
+ #palette::before { content: ''; display: block; width: 36px; height: 4px; margin: 8px auto 2px; border-radius: 4px; background: var(--border); flex: none; }
316
+ #palette input { border-radius: 0; flex: none; }
317
+ #palette ul { max-height: none; flex: 1; min-height: 0; }
318
+ #palette li { min-height: 46px; align-items: center; font-size: 15px; }
319
+ }
307
320
 
308
321
  .tour { position: fixed; inset: 0; z-index: 40; pointer-events: none; }
309
322
  .tour-spot { position: fixed; border-radius: 12px; box-shadow: 0 0 0 9999px color-mix(in srgb, var(--fg) 42%, transparent), 0 0 0 2px var(--white) inset; transition: left .35s var(--ease), top .35s var(--ease), width .35s var(--ease), height .35s var(--ease); }
@@ -458,7 +471,9 @@ dialog.sheet.wiz .sheet-in { padding: 0; gap: 0; flex: 1; min-height: 0; display
458
471
  .fs-pick b { overflow-wrap: anywhere; }
459
472
  .fs-list { max-height: none; }
460
473
  @media (max-width: 820px) {
461
- dialog.sheet.wiz { height: min(92dvh, calc(var(--vvh, 100dvh) - 8px)); }
474
+ /* Al mòbil, els assistents (Nova sessió, Afegeix ordinador…) fan l'alçada del contingut, no la de tota la pantalla. */
475
+ dialog.sheet.wiz { height: auto; max-height: min(92dvh, calc(var(--vvh, 100dvh) - 8px - var(--safe-t))); }
476
+ dialog.sheet.wiz .sheet-in, dialog.sheet.wiz .wiz-body { flex: 0 1 auto; }
462
477
  .wiz-head { padding: 10px 18px 8px; }
463
478
  .wiz-body { padding: 4px 18px 14px; }
464
479
  .wiz-foot { padding: 12px 18px calc(14px + var(--safe-b)); }
@@ -724,7 +739,7 @@ html.rd-open, html.rd-open body { overflow: hidden; }
724
739
  @media (max-width: 560px) { .rd-bar { padding: 8px 10px; gap: 8px; } .rd-stat, #rdFull { display: none; } .rd-seg button { padding: 9px 11px; } }
725
740
 
726
741
  .rd-stage img { transform-origin: center; will-change: transform; }
727
- .rd-tools { position: absolute; left: 50%; bottom: calc(14px + env(safe-area-inset-bottom, 0px)); transform: translateX(-50%); display: flex; align-items: center; gap: 6px; padding: 6px; border-radius: 14px; background: rgba(22, 22, 23, .86); border: 1px solid #36363a; backdrop-filter: blur(8px); z-index: 2; max-width: calc(100% - 20px); }
742
+ .rd-tools { position: absolute; left: 50%; bottom: calc(14px + var(--safe-b, 0px)); transform: translateX(-50%); display: flex; align-items: center; gap: 6px; padding: 6px; border-radius: 14px; background: rgba(22, 22, 23, .86); border: 1px solid #36363a; backdrop-filter: blur(8px); z-index: 2; max-width: calc(100% - 20px); }
728
743
  .rd-tools .rd-btn { padding: 10px 12px; min-width: 40px; }
729
744
  .rd-pct { min-width: 56px !important; font-variant-numeric: tabular-nums; }
730
745
  .rd-sep { width: 1px; align-self: stretch; background: #36363a; }
package/web/board.css CHANGED
@@ -109,7 +109,7 @@
109
109
  .bd-chat .chips::-webkit-scrollbar { display: none; }
110
110
  .bd-chat .chip-s { flex: none; scroll-snap-align: start; max-width: 260px; box-sizing: border-box; height: 32px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 12px; line-height: 30px; text-align: center; border: 1px solid var(--border); border-radius: 999px; background: var(--white); font-size: 12px; font-weight: 500; cursor: pointer; }
111
111
  .bd-chat .chip-s:hover { border-color: var(--fg); }
112
- .bd-chat .cin { display: flex; gap: 8px; align-items: flex-end; padding: 12px 14px calc(14px + env(safe-area-inset-bottom)); border-top: 1px solid var(--border); }
112
+ .bd-chat .cin { display: flex; gap: 8px; align-items: flex-end; padding: 12px 14px calc(14px + var(--safe-b, 0px)); border-top: 1px solid var(--border); }
113
113
  .bd-chat .cin textarea { flex: 1; min-height: 42px; max-height: 140px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 12px; background: var(--white); resize: none; font: inherit; font-size: 14px; line-height: 1.4; outline: none; }
114
114
  .bd-chat .cin textarea:focus { border-color: var(--fg); }
115
115
  .bd-chat .send { display: grid; place-items: center; width: 42px; height: 42px; flex: none; border: 0; border-radius: 12px; background: var(--fg); color: var(--bg); cursor: pointer; }
@@ -118,9 +118,9 @@
118
118
 
119
119
  @media (max-width: 1100px) { .board-root { --chat-w: 320px; } }
120
120
  @media (max-width: 820px) {
121
- .board-root { height: calc(100dvh - 56px - env(safe-area-inset-top)); }
121
+ .board-root { height: calc(100dvh - 56px - var(--safe-t, 0px)); }
122
122
  .chat-open .bd-tools, .chat-open .bd-right { transform: none; }
123
- .bd-tools { top: auto; bottom: calc(12px + env(safe-area-inset-bottom)); left: 10px; right: 10px; transform: none; overflow-x: auto; scrollbar-width: none; }
123
+ .bd-tools { top: auto; bottom: calc(12px + var(--safe-b, 0px)); left: 10px; right: 10px; transform: none; overflow-x: auto; scrollbar-width: none; }
124
124
  .bd-tools::-webkit-scrollbar { display: none; }
125
125
  .bd .tb { width: 40px; height: 40px; flex: none; }
126
126
  .bd .tb small { display: none; }
@@ -131,7 +131,7 @@
131
131
  .bd .chat-t > span:not(.bot) { display: none; }
132
132
  .only-m { display: grid !important; }
133
133
  .bd-style { display: none; }
134
- .style-open .bd-style:not([hidden]) { display: grid; left: 10px; right: 10px; top: auto; bottom: calc(70px + env(safe-area-inset-bottom)); width: auto; grid-template-columns: 1fr 1fr; }
134
+ .style-open .bd-style:not([hidden]) { display: grid; left: 10px; right: 10px; top: auto; bottom: calc(70px + var(--safe-b, 0px)); width: auto; grid-template-columns: 1fr 1fr; }
135
135
  .bd-help .cols { grid-template-columns: 1fr; }
136
136
  .bd-chat { position: absolute; top: auto; left: 0; right: 0; bottom: 0; z-index: 9; width: auto; height: min(78%, 600px); border: 0; border-top: 1px solid var(--border); border-radius: 20px 20px 0 0; transform: translateY(105%); transition: transform .34s var(--bd-ease), visibility 0s linear .34s; box-shadow: 0 -20px 50px -30px color-mix(in srgb, var(--fg) 55%, transparent); }
137
137
  .chat-open .bd-chat { transform: none; transition: transform .34s var(--bd-ease), visibility 0s; }
@@ -145,7 +145,7 @@
145
145
  .chat-open .bd-chat { transform: none; transition: transform .34s var(--bd-ease), visibility 0s; }
146
146
  .bd-chat header::before { content: ""; position: absolute; top: 6px; left: 50%; width: 36px; height: 4px; margin-left: -18px; border-radius: 4px; background: var(--border); }
147
147
  .chat-open .bd-tools, .chat-open .bd-right { transform: none; }
148
- .bd-tools { top: auto; bottom: calc(12px + env(safe-area-inset-bottom)); left: 10px; right: 10px; transform: none; overflow-x: auto; scrollbar-width: none; justify-content: flex-start; }
148
+ .bd-tools { top: auto; bottom: calc(12px + var(--safe-b, 0px)); left: 10px; right: 10px; transform: none; overflow-x: auto; scrollbar-width: none; justify-content: flex-start; }
149
149
  .bd-tools::-webkit-scrollbar { display: none; }
150
150
  .bd .tb { flex: none; }
151
151
  .bd .tb small { display: none; }
@@ -156,7 +156,7 @@
156
156
  .bd .chat-t > span:not(.bot) { display: none; }
157
157
  .bd .tb.only-m { display: grid !important; }
158
158
  .bd-style { display: none; }
159
- .style-open .bd-style:not([hidden]) { display: grid; left: 10px; right: 10px; top: auto; bottom: calc(70px + env(safe-area-inset-bottom)); width: auto; grid-template-columns: 1fr 1fr; }
159
+ .style-open .bd-style:not([hidden]) { display: grid; left: 10px; right: 10px; top: auto; bottom: calc(70px + var(--safe-b, 0px)); width: auto; grid-template-columns: 1fr 1fr; }
160
160
  }
161
161
  @container stage (max-width: 440px) {
162
162
  .bd .tb[data-tool="hand"], .bd .tb[data-tool="line"], .bd .tb[data-bd="lock"] { display: none; }
@@ -181,7 +181,7 @@
181
181
  .bd-chat header { padding-top: 22px; }
182
182
  .bd-chat .msgs { overscroll-behavior: contain; }
183
183
  .bd-chat .cin textarea { font-size: 16px; }
184
- .bd-chat .cin { padding-bottom: calc(12px + env(safe-area-inset-bottom)); }
184
+ .bd-chat .cin { padding-bottom: calc(12px + var(--safe-b, 0px)); }
185
185
  }
186
186
  @container stage (max-width: 720px) {
187
187
  .bd-scrim { display: block; position: absolute; inset: 0; z-index: 8; border: 0; background: color-mix(in srgb, var(--fg) 30%, transparent); opacity: 0; visibility: hidden; transition: opacity .3s, visibility 0s linear .3s; }
@@ -232,10 +232,10 @@
232
232
  /* Mòbil en horitzontal: poc d'alçada */
233
233
  @media (max-height: 480px) and (max-width: 940px) {
234
234
  .bd-chat { height: min(92%, 600px); }
235
- .bd-tools { bottom: calc(6px + env(safe-area-inset-bottom)); left: auto; right: auto; max-width: calc(100% - 20px); }
235
+ .bd-tools { bottom: calc(6px + var(--safe-b, 0px)); left: auto; right: auto; max-width: calc(100% - 20px); }
236
236
  .bd-tools { left: 50%; transform: translateX(-50%); }
237
237
  .bd .tb { height: 40px; }
238
- .style-open .bd-style:not([hidden]) { bottom: calc(58px + env(safe-area-inset-bottom)); max-height: calc(100% - 130px); overflow-y: auto; grid-template-columns: repeat(2, 1fr); }
238
+ .style-open .bd-style:not([hidden]) { bottom: calc(58px + var(--safe-b, 0px)); max-height: calc(100% - 130px); overflow-y: auto; grid-template-columns: repeat(2, 1fr); }
239
239
  .bd-help-in { padding: 14px; }
240
240
  .bd-empty { display: none; }
241
241
  }
package/web/index.html CHANGED
@@ -218,6 +218,7 @@ main { padding-bottom: 8px; }
218
218
  <section aria-labelledby="que">
219
219
  <div class="sec-head"><span class="eyebrow">Què hi pots fer</span><h2 id="que">Tot el que necessites per treballar amb agents</h2></div>
220
220
  <div class="grid">
221
+ <article class="card big c-lemon"><div class="big-l"><span class="tag">Notes</span><h3>Classe o reunió, apuntada sola</h3><p>Grava amb el micròfon del mòbil o de l’ordinador i Whisper ho transcriu a l’ordinador mentre es parla. Cada 4 o 5 minuts, un model en fa apunts; quan acabes, surt el resum de tot.</p><p class="muted">Tries l’idioma (català, castellà o anglès) i el model. L’àudio no surt dels teus ordinadors.</p></div><dl class="rows stack"><div><dt><strong>En directe</strong></dt><dd>La transcripció apareix cada 20 segons, al costat dels apunts.</dd></div><div><dt><strong>Classe</strong></dt><dd>Conceptes clau, punts importants i preguntes per repassar.</dd></div><div><dt><strong>Reunió</strong></dt><dd>Resum, decisions, tasques amb qui se’n fa càrrec i temes oberts.</dd></div><div><dt><strong>App d’Android</strong></dt><dd>Continua gravant amb la pantalla bloquejada, i si es talla la connexió guarda els trossos fins que torna.</dd></div></dl></article>
221
222
  <article class="card c-blue"><span class="tag">Agents</span><h3>Sessions en directe</h3><p>Claude Code, Codex, Grok i Antigravity a qualsevol dels teus ordinadors. Tries la carpeta i els permisos, i la resposta arriba mentre treballa.</p></article>
222
223
  <article class="card c-rose"><span class="tag">Seguretat</span><h3>Aprovacions</h3><p>Abans d’editar, executar o clicar res, te’n demana permís amb el motiu. Ho aproves o ho rebutges amb un toc, i tot queda registrat.</p></article>
223
224
  <article class="card c-lilac"><span class="tag">Connexió</span><h3>Avisos al mòbil</h3><p>Una notificació quan un agent et demana permís o acaba. Arriba buida: el detall el demana el mòbil directament a l’ordinador.</p></article>
@@ -230,6 +231,9 @@ main { padding-bottom: 8px; }
230
231
  <article class="card c-blue"><span class="tag">Agents</span><h3>Git i GitHub</h3><p>Clona un repositori, mira els canvis, fes commit i crea un PR des del mòbil, amb el <code>git</code> i el <code>gh</code> de l’ordinador.</p></article>
231
232
  <article class="card c-lilac"><span class="tag">Connexió</span><h3>Telegram i veu</h3><p>Parla amb el teu bot o amb notes de veu, aprova amb un botó i escolta les respostes. Dictes gratis amb Whisper, que funciona a l’ordinador: l’àudio no en surt.</p></article>
232
233
  <article class="card c-green"><span class="tag">Xat local</span><h3>Ollama i xat local</h3><p>Fes servir models gratuïts a l’ordinador, o un model petit dins del navegador del mòbil, sense connexió.</p></article>
234
+ <article class="card c-peach"><span class="tag">Notes</span><h3>Notes i veu</h3><p>Notes amb llistes, colors i etiquetes, guardades a l’ordinador. Dicta-les o adjunta-hi àudio i es transcriu; llisca una nota per arxivar-la. Des de l’ordinador, pots fer servir el mòbil com a micròfon.</p></article>
235
+ <article class="card c-rose"><span class="tag">Agents</span><h3>Truca a en Nio</h3><p>Parla amb en Nio en temps real (ElevenLabs, amb la teva clau): et diu com van les sessions, li encarregues feina i aproves de paraula. La conversa surt en directe, paraula per paraula.</p></article>
236
+ <article class="card c-blue"><span class="tag">Agents</span><h3>Continua les sessions del terminal</h3><p>Les converses de Claude Code obertes al terminal de l’ordinador surten a Sessions. Toca’n una i continua-la des del mòbil amb tot l’historial.</p></article>
233
237
  </div>
234
238
  </section>
235
239
 
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-v53';
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(() => {}));