nio-cat 0.9.11 → 0.9.13

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.13",
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/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 }, () => {});
package/web/app/app.js CHANGED
@@ -583,7 +583,7 @@ function renderChrome() {
583
583
  }
584
584
  let notesCtl = null, callCtl = null;
585
585
  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 }));
586
+ 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
587
  function render() {
588
588
  const r = state.route, view = $('#view'), board = r.name === 'pissarra';
589
589
  if (r.name !== 'sessions') manage = null;
@@ -738,10 +738,12 @@ function noMachine(text) {
738
738
  function viewSettings() {
739
739
  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
740
  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>` : '';
741
+ 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
742
  if (machine() && pushOn === null) refreshPush();
743
743
  if (machine() && tgState === undefined) loadTelegram();
744
- return `${head}<div class="stack-lg cols">${avisos}${voicePanel()}<div id="tgPanel">${telegramPanel()}</div></div>`;
744
+ // App d'Android: només es proposa des del navegador d'un mòbil Android (dins l'app ja no cal).
745
+ 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>` : '';
746
+ return `${head}<div class="stack-lg cols">${android}${avisos}${voicePanel()}<div id="tgPanel">${telegramPanel()}</div></div>`;
745
747
  }
746
748
  // Ajustos sense ordinador: un sol avís a dalt i les seccions de mostra, desactivades.
747
749
  function settingsOff() {
@@ -806,15 +808,27 @@ async function sshAdd(target, onStep, via) {
806
808
  if (!last || last.error) throw new Error(last?.error || 'S’ha tallat la connexió');
807
809
  return last;
808
810
  }
811
+ // Lector de QR: BarcodeDetector si el navegador en té; si no (Firefox, l'app d'Android), jsQR, que llegeix la imatge en JavaScript.
812
+ let jsQRload = null;
813
+ 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
814
  async function startScan(video, onText) {
810
- const det = new BarcodeDetector({ formats: ['qr_code'] });
815
+ let detect;
816
+ if ('BarcodeDetector' in window) { const det = new BarcodeDetector({ formats: ['qr_code'] }); detect = async () => (await det.detect(video))[0]?.rawValue; }
817
+ else {
818
+ const jsQR = await loadJsQR(), cv = document.createElement('canvas'), g = cv.getContext('2d', { willReadFrequently: true });
819
+ detect = async () => {
820
+ const w = Math.min(640, video.videoWidth), h = Math.round(video.videoHeight * (w / video.videoWidth)); if (!w || !h) return null;
821
+ cv.width = w; cv.height = h; g.drawImage(video, 0, 0, w, h);
822
+ return jsQR(g.getImageData(0, 0, w, h).data, w, h, { inversionAttempts: 'dontInvert' })?.data;
823
+ };
824
+ }
811
825
  const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
812
826
  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); } })();
827
+ (async () => { while (alive) { try { const t = await detect(); if (t) onText(t); } catch { /* quadre sense QR */ } await sleep(250); } })();
814
828
  return { stop() { alive = false; stream.getTracks().forEach((t) => t.stop()); } };
815
829
  }
816
830
  function addComputerSheet(tab, opts = {}) {
817
- const phone = isPhone(), canScan = 'BarcodeDetector' in window && !!navigator.mediaDevices?.getUserMedia;
831
+ const phone = isPhone(), canScan = !!navigator.mediaDevices?.getUserMedia;
818
832
  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
833
  let cur = TABS.some((t) => t[0] === tab) ? tab : TABS[0][0], poll = 0, scan = null, shown = '', hosts = [];
820
834
  const intro = (t, d) => `<div class="add-intro"><h3>${t}</h3>${d ? `<p>${d}</p>` : ''}</div>`;
@@ -1663,8 +1677,7 @@ $('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
1663
1677
 
1664
1678
  // Al mòbil, els fulls es tanquen lliscant cap avall, com els de l'iPhone: el full segueix el dit
1665
1679
  // 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');
1680
+ function dragToClose(d, close) {
1668
1681
  let g = null;
1669
1682
  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
1683
  const reset = () => { d.style.transition = ''; d.style.transform = ''; };
@@ -1692,21 +1705,27 @@ $('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
1692
1705
  const x = g; g = null; if (!x || x.lock !== 'y') return;
1693
1706
  const [t0, y0] = x.pts[0], [t1, y1] = x.pts[x.pts.length - 1], v = (y1 - y0) / Math.max(1, t1 - t0);
1694
1707
  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; }
1708
+ if (reduce) { reset(); close(); return; }
1696
1709
  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);
1710
+ setTimeout(() => { close(); requestAnimationFrame(reset); }, 200);
1698
1711
  } else { d.style.transition = 'transform .3s cubic-bezier(.22,1,.36,1)'; d.style.transform = ''; setTimeout(reset, 300); }
1699
1712
  };
1700
1713
  d.addEventListener('touchend', end, { passive: true });
1701
1714
  d.addEventListener('touchcancel', end, { passive: true });
1702
1715
  d.addEventListener('close', reset);
1703
- })();
1716
+ }
1717
+ dragToClose($('#sheet'), () => closeSheet());
1718
+ dragToClose($('#palette'), () => $('#palette').close());
1704
1719
 
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';
1720
+ // Claude Code no deixa «Via lliure» (bypassPermissions) quan nio corre com a root (típic en un VPS).
1721
+ // 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.
1722
+ 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; };
1723
+ const asRoot = (agentId) => agentId === 'claude' && state.info?.user === 'root';
1724
+ const modeBlocked = (agentId, mode) => mode === 'tot' && asRoot(agentId) && !verAtLeast(state.info?.version, '0.9.12');
1707
1725
  function modeHelp(agentId, mode) {
1708
1726
  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.';
1727
+ 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».';
1728
+ 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
1729
  if (mode === 'llegir') return 'Llegeix el projecte i et proposa canvis, però no toca cap fitxer.';
1711
1730
  if (mode === 'tot') return 'Treballa sense preguntar. Fes-lo servir només en carpetes on confiïs plenament.';
1712
1731
  if (a?.approvals) return 'Llegeix sol. Abans d’editar un fitxer o d’executar una ordre, et demana permís aquí.';
@@ -3041,6 +3060,20 @@ if ('serviceWorker' in navigator) {
3041
3060
  navigator.serviceWorker.register('/sw.js').catch(() => {});
3042
3061
  navigator.serviceWorker.addEventListener('message', (e) => { if (e.data?.type === 'open-session' && e.data.id) go(`#/s/${e.data.id}`); });
3043
3062
  }
3063
+ // 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ó
3064
+ // 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.
3065
+ (() => {
3066
+ const ver = async () => { try { return (await (await fetch('/sw.js', { cache: 'no-store' })).text()).match(/nio-shell-v\d+/)?.[0] || null; } catch { return null; } };
3067
+ 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());
3068
+ let mine = null, hiddenAt = 0;
3069
+ ver().then((v) => { mine = v; });
3070
+ document.addEventListener('visibilitychange', async () => {
3071
+ if (document.hidden) { hiddenAt = Date.now(); return; }
3072
+ if (!mine || Date.now() - hiddenAt < 60000) return;
3073
+ const now = await ver();
3074
+ if (now && now !== mine && !document.hidden && !busy()) location.reload();
3075
+ });
3076
+ })();
3044
3077
  addEventListener('hashchange', route);
3045
3078
  // 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
3079
  // 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.
@@ -3073,11 +3106,12 @@ if (T.on) tourGo(T.i); else if (!T.seen && !machine()) setTimeout(tourStart, 700
3073
3106
 
3074
3107
  /* ---------------------------------------------------------------- lliscar entre pàgines (mòbil) */
3075
3108
  (() => {
3076
- const PAGES = ['sessions', 'approvals', 'ordinadors'];
3109
+ // Les pàgines de la barra de baix, en el mateix ordre («Pantalla» i «Més» no són pàgines: obren un full).
3110
+ const PAGES = ['sessions', 'notes', 'ordinadors'];
3077
3111
  const view = $('#view');
3078
3112
  let g = null, timer = 0;
3079
3113
  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);
3114
+ 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
3115
  const target = (dir) => PAGES[PAGES.indexOf(state.route.name) + dir];
3082
3116
  const put = (x, o) => { view.style.setProperty('--sx', x + 'px'); view.style.setProperty('--so', String(o)); };
3083
3117
  const clear = () => { view.classList.remove('swiping', 'swipe-anim'); view.style.removeProperty('--sx'); view.style.removeProperty('--so'); };
@@ -32,6 +32,7 @@
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>
35
36
  <link rel="stylesheet" href="/bot.css">
36
37
  <link rel="stylesheet" href="/board.css">
37
38
  <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
@@ -173,6 +173,8 @@ body:has(.nt-ov) { overflow: hidden; }
173
173
  .nt-ai-f { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; }
174
174
 
175
175
  @media (max-width: 700px) {
176
+ /* Els desplegables de la nota (color, etiqueta, assistent) ocupen l'amplada, amb marge: no se surten pels costats. */
177
+ .nt-pop { left: 10px !important; right: 10px; max-width: none; min-width: 0; }
176
178
  .page:has(.nt) { padding-inline: 14px; }
177
179
  .nt-title h1 { font-size: 26px; } .nt-title p { display: none; }
178
180
  .nt-top { margin-bottom: 12px; } .nt-tools .btn span { display: none; } .nt-tools .btn { padding: 0 11px; }
package/web/app/style.css CHANGED
@@ -304,6 +304,15 @@ dialog.sheet[open] { animation: pop .22s var(--ease); }
304
304
  #palette li { display: flex; justify-content: space-between; gap: 12px; padding: 10px 12px; border-radius: 10px; cursor: pointer; font-size: 14px; }
305
305
  #palette li[aria-selected="true"] { background: var(--bg-2); }
306
306
  #palette li span { color: var(--fg-muted); font-size: 12px; }
307
+ /* Al mòbil, la paleta és un full de baix com els altres, i es tanca lliscant cap avall. */
308
+ @media (max-width: 820px) {
309
+ #palette { width: 100%; max-width: none; margin: auto 0 0; border-radius: 20px 20px 0 0; padding-bottom: var(--safe-b); max-height: 82dvh; }
310
+ #palette[open] { display: flex; flex-direction: column; animation: sheet-up .3s var(--ease); }
311
+ #palette::before { content: ''; display: block; width: 36px; height: 4px; margin: 8px auto 2px; border-radius: 4px; background: var(--border); flex: none; }
312
+ #palette input { border-radius: 0; flex: none; }
313
+ #palette ul { max-height: none; flex: 1; min-height: 0; }
314
+ #palette li { min-height: 46px; align-items: center; font-size: 15px; }
315
+ }
307
316
 
308
317
  .tour { position: fixed; inset: 0; z-index: 40; pointer-events: none; }
309
318
  .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 +467,9 @@ dialog.sheet.wiz .sheet-in { padding: 0; gap: 0; flex: 1; min-height: 0; display
458
467
  .fs-pick b { overflow-wrap: anywhere; }
459
468
  .fs-list { max-height: none; }
460
469
  @media (max-width: 820px) {
461
- dialog.sheet.wiz { height: min(92dvh, calc(var(--vvh, 100dvh) - 8px)); }
470
+ /* Al mòbil, els assistents (Nova sessió, Afegeix ordinador…) fan l'alçada del contingut, no la de tota la pantalla. */
471
+ dialog.sheet.wiz { height: auto; max-height: min(92dvh, calc(var(--vvh, 100dvh) - 8px - var(--safe-t))); }
472
+ dialog.sheet.wiz .sheet-in, dialog.sheet.wiz .wiz-body { flex: 0 1 auto; }
462
473
  .wiz-head { padding: 10px 18px 8px; }
463
474
  .wiz-body { padding: 4px 18px 14px; }
464
475
  .wiz-foot { padding: 12px 18px calc(14px + var(--safe-b)); }
@@ -724,7 +735,7 @@ html.rd-open, html.rd-open body { overflow: hidden; }
724
735
  @media (max-width: 560px) { .rd-bar { padding: 8px 10px; gap: 8px; } .rd-stat, #rdFull { display: none; } .rd-seg button { padding: 9px 11px; } }
725
736
 
726
737
  .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); }
738
+ .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
739
  .rd-tools .rd-btn { padding: 10px 12px; min-width: 40px; }
729
740
  .rd-pct { min-width: 56px !important; font-variant-numeric: tabular-nums; }
730
741
  .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,5 +1,5 @@
1
1
  // nio · service worker: obre l'app encara que no hi hagi connexió i mostra els avisos.
2
- const SHELL = 'nio-shell-v53';
2
+ const SHELL = 'nio-shell-v59';
3
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'];
4
4
 
5
5
  self.addEventListener('install', (e) => {