nio-cat 0.9.10 → 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/web/app/app.js CHANGED
@@ -206,8 +206,11 @@ async function api(path, opts = {}) {
206
206
  const body = opts.body && typeof opts.body !== 'string' ? opts.body : opts.body ? JSON.parse(opts.body) : undefined;
207
207
  let res;
208
208
  try { res = await fetch(m.url + path, { ...opts, headers: { Authorization: `Bearer ${m.token}`, 'content-type': 'application/json', ...(opts.headers || {}) }, body: body ? JSON.stringify(body) : undefined }); }
209
- catch (e) { if (e.name === 'AbortError') throw e; throw new Error(`No he pogut parlar amb ${m.name || 'l’ordinador'}. Comprova que nio hi estigui engegat i torna-ho a provar.`); }
210
- const data = await res.json().catch(() => ({}));
209
+ catch (e) { if (e.name === 'AbortError') throw e; throw Object.assign(new Error(`No he pogut parlar amb ${m.name || 'l’ordinador'}. Comprova que nio hi estigui engegat i torna-ho a provar.`), { unreachable: true }); }
210
+ const data = await res.json().catch(() => null);
211
+ // Un túnel mort respon una pàgina d'error de Cloudflare (502, 530…), no el JSON de nio: és com no arribar-hi.
212
+ if (!data && res.status >= 500) throw Object.assign(new Error(`No arribo a ${m.name || 'l’ordinador'}`), { unreachable: true });
213
+ if (!data) { if (!res.ok) throw new Error(`Error ${res.status}`); return {}; }
211
214
  if (!res.ok) throw new Error(data.error || `Error ${res.status}`);
212
215
  if (Array.isArray(data.agents)) data.agents = data.agents.filter((x) => x.id !== 'demo'); // l'agent de proves del programa no s'ensenya
213
216
  return data;
@@ -320,11 +323,13 @@ async function connect() {
320
323
  } catch (e) {
321
324
  if (ctrl.signal.aborted) return;
322
325
  state.connected = false;
323
- state.lastError = e.message === 'Failed to fetch' || e.name === 'TypeError' ? (location.protocol === 'https:' && /^http:\/\/(127\.0\.0\.1|localhost)/.test(m.url) ? 'El navegador no deixa que aquesta web parli amb aquest ordinador. Prova Chrome, o engega nio amb --local.' : 'No arribo a l’ordinador') : e.message;
326
+ const unreachable = e.unreachable || e.message === 'Failed to fetch' || e.name === 'TypeError' || /^Error 5\d\d$/.test(e.message);
327
+ state.lastError = unreachable ? (location.protocol === 'https:' && /^http:\/\/(127\.0\.0\.1|localhost)/.test(m.url) ? 'El navegador no deixa que aquesta web parli amb aquest ordinador. Prova Chrome, o engega nio amb --local.' : 'No arribo a l’ordinador') : e.message;
324
328
  render();
325
329
  if (e.fatal) return;
326
330
  // Potser el túnel ha canviat d'adreça: la busquem a nio.cat (signada per l'ordinador).
327
- if (state.lastError === 'No arribo a l’ordinador' && (await relocate(store.active))) { if (!ctrl.signal.aborted) connect(); return; }
331
+ // (Passa sobretot quan es torna a executar install.sh: el túnel ràpid de Cloudflare canvia d'adreça.)
332
+ if (unreachable && (await relocate(store.active))) { if (!ctrl.signal.aborted) connect(); return; }
328
333
  if (m.kind === 'vps' && m.via && retry >= 2) autoReconnect(m);
329
334
  setTimeout(() => machine() && !ctrl.signal.aborted && connect(), Math.min(15000, 1000 * 2 ** retry++));
330
335
  }
@@ -578,7 +583,7 @@ function renderChrome() {
578
583
  }
579
584
  let notesCtl = null, callCtl = null;
580
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: () => {} }));
581
- 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 }));
582
587
  function render() {
583
588
  const r = state.route, view = $('#view'), board = r.name === 'pissarra';
584
589
  if (r.name !== 'sessions') manage = null;
@@ -733,10 +738,12 @@ function noMachine(text) {
733
738
  function viewSettings() {
734
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>';
735
740
  if (!machine()) return `${head}<div class="stack-lg" style="max-width:640px">${settingsOff()}</div>`;
736
- 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>` : '';
737
742
  if (machine() && pushOn === null) refreshPush();
738
743
  if (machine() && tgState === undefined) loadTelegram();
739
- return `${head}<div class="stack-lg" style="max-width:640px">${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>`;
740
747
  }
741
748
  // Ajustos sense ordinador: un sol avís a dalt i les seccions de mostra, desactivades.
742
749
  function settingsOff() {
@@ -801,15 +808,27 @@ async function sshAdd(target, onStep, via) {
801
808
  if (!last || last.error) throw new Error(last?.error || 'S’ha tallat la connexió');
802
809
  return last;
803
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); }));
804
814
  async function startScan(video, onText) {
805
- 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
+ }
806
825
  const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
807
826
  video.srcObject = stream; await video.play(); let alive = true;
808
- (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); } })();
809
828
  return { stop() { alive = false; stream.getTracks().forEach((t) => t.stop()); } };
810
829
  }
811
830
  function addComputerSheet(tab, opts = {}) {
812
- const phone = isPhone(), canScan = 'BarcodeDetector' in window && !!navigator.mediaDevices?.getUserMedia;
831
+ const phone = isPhone(), canScan = !!navigator.mediaDevices?.getUserMedia;
813
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]];
814
833
  let cur = TABS.some((t) => t[0] === tab) ? tab : TABS[0][0], poll = 0, scan = null, shown = '', hosts = [];
815
834
  const intro = (t, d) => `<div class="add-intro"><h3>${t}</h3>${d ? `<p>${d}</p>` : ''}</div>`;
@@ -1658,8 +1677,7 @@ $('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
1658
1677
 
1659
1678
  // Al mòbil, els fulls es tanquen lliscant cap avall, com els de l'iPhone: el full segueix el dit
1660
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.
1661
- (() => {
1662
- const d = $('#sheet');
1680
+ function dragToClose(d, close) {
1663
1681
  let g = null;
1664
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; };
1665
1683
  const reset = () => { d.style.transition = ''; d.style.transform = ''; };
@@ -1687,21 +1705,27 @@ $('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
1687
1705
  const x = g; g = null; if (!x || x.lock !== 'y') return;
1688
1706
  const [t0, y0] = x.pts[0], [t1, y1] = x.pts[x.pts.length - 1], v = (y1 - y0) / Math.max(1, t1 - t0);
1689
1707
  if (e.type === 'touchend' && (x.dy > Math.min(160, d.offsetHeight * 0.28) || (v > 0.5 && x.dy > 20))) {
1690
- if (reduce) { reset(); closeSheet(); return; }
1708
+ if (reduce) { reset(); close(); return; }
1691
1709
  d.style.transition = 'transform .2s cubic-bezier(.4,0,1,1)'; d.style.transform = `translateY(${d.offsetHeight + 20}px)`;
1692
- setTimeout(() => { closeSheet(); requestAnimationFrame(reset); }, 200);
1710
+ setTimeout(() => { close(); requestAnimationFrame(reset); }, 200);
1693
1711
  } else { d.style.transition = 'transform .3s cubic-bezier(.22,1,.36,1)'; d.style.transform = ''; setTimeout(reset, 300); }
1694
1712
  };
1695
1713
  d.addEventListener('touchend', end, { passive: true });
1696
1714
  d.addEventListener('touchcancel', end, { passive: true });
1697
1715
  d.addEventListener('close', reset);
1698
- })();
1716
+ }
1717
+ dragToClose($('#sheet'), () => closeSheet());
1718
+ dragToClose($('#palette'), () => $('#palette').close());
1699
1719
 
1700
- // Claude Code no deixa «Via lliure» quan nio corre com a root (típic en un VPS).
1701
- 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');
1702
1725
  function modeHelp(agentId, mode) {
1703
1726
  const a = state.info?.agents?.find((x) => x.id === agentId);
1704
- 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.';
1705
1729
  if (mode === 'llegir') return 'Llegeix el projecte i et proposa canvis, però no toca cap fitxer.';
1706
1730
  if (mode === 'tot') return 'Treballa sense preguntar. Fes-lo servir només en carpetes on confiïs plenament.';
1707
1731
  if (a?.approvals) return 'Llegeix sol. Abans d’editar un fitxer o d’executar una ordre, et demana permís aquí.';
@@ -2632,7 +2656,20 @@ function voicePanel() {
2632
2656
  const on = [...(v?.whisper?.ready ? ['Whisper'] : []), ...Object.entries(P).filter(([, p]) => p.key).map(([, p]) => p.name)];
2633
2657
  return `<section class="panel"><header><h2>Veu</h2>${on.length ? `<span class="chip ok"><i class="dot on"></i>${esc(on.join(' · '))}</span>` : ''}</header><div class="body stack"><p class="small">Dicta els missatges amb el micròfon i escolta les respostes. Amb <b>Whisper</b> és gratis i l’àudio no surt de l’ordinador (${esc(machine().name)}). Si hi poses una clau d’ElevenLabs o OpenAI, també es queda a l’ordinador.</p>${v ? '' : '<p class="muted small">Aquest ordinador encara no té la veu (cal nio 0.7 o més nou).</p>'}<div class="row"><button class="btn ink" data-act="voice" type="button" ${v ? '' : 'disabled'}>${on.length ? 'Canvia la configuració' : 'Configura la veu'}</button></div></div></section>`;
2634
2658
  }
2635
- async function voiceSheet() {
2659
+ // Pas a pas per fer la clau d'ElevenLabs amb només els permisos que fa servir nio.
2660
+ const EL_GUIDE = `<details class="el-guide" id="elGuide"><summary>Com faig la clau? Pas a pas</summary><ol>
2661
+ <li>Obre <a href="https://elevenlabs.io/app/developers/api-keys" target="_blank" rel="noopener">elevenlabs.io → Developers → API Keys</a> i prem <b>Create API Key</b>.</li>
2662
+ <li><b>Name</b>: «nio». <b>Expire After</b>: «Never» (o la data que vulguis).</li>
2663
+ <li>Activa <b>Restrict Key</b> i, a <b>Endpoints</b>, deixa-ho així:<ul>
2664
+ <li><b>Text to Speech</b> → <span class="el-on">Access</span> · per escoltar les respostes</li>
2665
+ <li><b>ElevenAgents</b> → <span class="el-on">Write</span> · per a la Trucada amb en Nio</li>
2666
+ <li><b>Speech to Text</b> → <span class="el-on">Access</span> · només si vols que ElevenLabs transcrigui en lloc de Whisper</li>
2667
+ <li>Tota la resta, també <b>Administration</b> → <span class="el-off">No Access</span></li></ul></li>
2668
+ <li><b>Usage Limits</b> (opcional): posa un màxim de crèdits, perquè no gasti més del que vols.</li>
2669
+ <li>Deixa activat <b>Auto-disable if leaked</b>.</li>
2670
+ <li>Prem <b>Create</b>, copia la clau (<span class="mono">sk_…</span>; només es veu un cop) i enganxa-la aquí sobre. Es desa a l’ordinador i no torna mai al mòbil.</li>
2671
+ </ol><p class="muted small">Si falta algun permís, nio et dirà quin has d’activar.</p></details>`;
2672
+ async function voiceSheet(opts = {}) {
2636
2673
  await loadVoice();
2637
2674
  if (!nodeVoice) return toast('Aquest ordinador encara no té la veu. Actualitza nio (npx nio-cat@latest).', 5000);
2638
2675
  if (isDemo()) return toast('A la demo no hi ha veu. Amb nio instal·lat, dictes amb Whisper, gratis.', 4500);
@@ -2642,6 +2679,7 @@ async function voiceSheet() {
2642
2679
  <form class="stack" id="voiceForm" style="margin-top:14px">
2643
2680
  <details class="voice-extras"><summary>Extres opcionals · ElevenLabs i OpenAI</summary><div class="stack"><p class="muted small">Afegeix una clau per fer servir aquests serveis. Whisper funciona sense cap clau.</p>
2644
2681
  <label class="f"><span>Clau d’ElevenLabs ${v.providers.elevenlabs.key ? `<span class="muted">(desada: ${esc(v.providers.elevenlabs.key)})</span>` : ''}</span><input type="password" id="vEl" placeholder="${v.providers.elevenlabs.key ? 'Deixa-ho buit per mantenir-la' : 'sk_…'}" value="${!v.providers.elevenlabs.key && old.key ? esc(old.key) : ''}" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
2682
+ ${EL_GUIDE}
2645
2683
  <label class="f"><span>Clau d’OpenAI ${v.providers.openai.key ? `<span class="muted">(desada: ${esc(v.providers.openai.key)})</span>` : ''}</span><input type="password" id="vOa" placeholder="${v.providers.openai.key ? 'Deixa-ho buit per mantenir-la' : 'sk-…'}" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
2646
2684
  <div class="grid-2"><label class="f"><span>Per entendre’t</span><select id="vStt"><option value="">Automàtic</option>${v.whisper ? `<option value="whisper" ${v.stt === 'whisper' ? 'selected' : ''}>Whisper (gratis)</option>` : ''}<option value="elevenlabs" ${v.stt === 'elevenlabs' ? 'selected' : ''}>ElevenLabs Scribe</option><option value="openai" ${v.stt === 'openai' ? 'selected' : ''}>OpenAI</option></select></label>
2647
2685
  <label class="f"><span>Per parlar</span><select id="vTts"><option value="">Automàtic</option><option value="elevenlabs" ${v.tts === 'elevenlabs' ? 'selected' : ''}>ElevenLabs</option><option value="openai" ${v.tts === 'openai' ? 'selected' : ''}>OpenAI</option></select></label></div>
@@ -2650,6 +2688,7 @@ async function voiceSheet() {
2650
2688
  <button class="btn ghost" type="button" id="vOff">Treu les claus</button></div></details>
2651
2689
  <div class="row sheet-foot"><button class="btn ink" type="submit">Desa</button></div>
2652
2690
  </form>`, (sh) => {
2691
+ if (opts.guide) { const g = $('#elGuide', sh); sh.querySelector('.voice-extras').open = true; g.open = true; setTimeout(() => g.scrollIntoView({ block: 'start', behavior: 'smooth' }), 120); }
2653
2692
  const put = async (body, msg) => { try { nodeVoice = await api('/api/voice', { method: 'PUT', body }); try { localStorage.removeItem('nio.voice'); } catch { /* res */ } closeSheet(); toast(msg); if (state.route.name === 'session') renderSession(); else render(); } catch (e) { toast(e.message, 4500); } };
2654
2693
  $('#voiceForm', sh).onsubmit = (e) => { e.preventDefault(); const keys = {}; const el = $('#vEl', sh).value.trim(), oa = $('#vOa', sh).value.trim(); if (el) keys.elevenlabs = el; if (oa) keys.openai = oa; put({ keys, stt: $('#vStt', sh).value || null, tts: $('#vTts', sh).value || null, speak: $('#vSpeak', sh).checked }, 'Veu desada a l’ordinador'); };
2655
2694
  $('#vOff', sh).onclick = () => { if (confirm('Es treuran les claus de veu d’aquest ordinador.')) put({ keys: { elevenlabs: null, openai: null } }, 'Claus eliminades'); };
@@ -3021,6 +3060,20 @@ if ('serviceWorker' in navigator) {
3021
3060
  navigator.serviceWorker.register('/sw.js').catch(() => {});
3022
3061
  navigator.serviceWorker.addEventListener('message', (e) => { if (e.data?.type === 'open-session' && e.data.id) go(`#/s/${e.data.id}`); });
3023
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
+ })();
3024
3077
  addEventListener('hashchange', route);
3025
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,
3026
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.
@@ -3053,11 +3106,12 @@ if (T.on) tourGo(T.i); else if (!T.seen && !machine()) setTimeout(tourStart, 700
3053
3106
 
3054
3107
  /* ---------------------------------------------------------------- lliscar entre pàgines (mòbil) */
3055
3108
  (() => {
3056
- 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'];
3057
3111
  const view = $('#view');
3058
3112
  let g = null, timer = 0;
3059
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; };
3060
- 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
3061
3115
  const target = (dir) => PAGES[PAGES.indexOf(state.route.name) + dir];
3062
3116
  const put = (x, o) => { view.style.setProperty('--sx', x + 'px'); view.style.setProperty('--so', String(o)); };
3063
3117
  const clear = () => { view.classList.remove('swiping', 'swipe-anim'); view.style.removeProperty('--sx'); view.style.removeProperty('--so'); };
package/web/app/call.js CHANGED
@@ -43,7 +43,7 @@ export function createCall(ctx) {
43
43
  if (!m) return head + ctx.noMachine('La trucada parla amb un ordinador. Connecta’n un per trucar.');
44
44
  const ready = demo || st?.ready, loading = !demo && !st;
45
45
  const why = st?.error ? `<p class="call-why err">${esc(OLD.test(st.error) ? 'Aquest ordinador encara no sap trucar: actualitza nio (cal nio-cat 0.9.9 o més nou).' : st.error)}</p>`
46
- : !ready && !loading ? '<p class="call-why">Per trucar cal una clau d’ElevenLabs. Es queda a l’ordinador i el cost va al teu compte d’ElevenLabs.</p>' : '';
46
+ : !ready && !loading ? '<p class="call-why">Per trucar cal una clau d’ElevenLabs amb el permís «ElevenAgents → Write». Es queda a l’ordinador i el cost va al teu compte d’ElevenLabs. <button class="linkish" type="button" data-call="guide">Com faig la clau?</button></p>' : '';
47
47
  return `${head}
48
48
  <section class="call-hero">
49
49
  <div class="call-face" aria-hidden="true"><span class="call-ring"></span><span class="call-ring r2"></span>${bot('idle', 'call-bot')}</div>
@@ -62,6 +62,7 @@ export function createCall(ctx) {
62
62
  const a = b.dataset.call;
63
63
  if (a === 'start') start();
64
64
  else if (a === 'voice') ctx.voiceSheet();
65
+ else if (a === 'guide') ctx.voiceSheet({ guide: true });
65
66
  });
66
67
 
67
68
  // ---------------------------------------------------------------- pantalla de la trucada
@@ -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">