nio-cat 0.9.7 → 0.9.10

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
@@ -2,6 +2,7 @@
2
2
  // Sense frameworks ni compilació. També inclou un ordinador de prova que viu dins del navegador.
3
3
 
4
4
  import { createNotes } from './notes.js';
5
+ import { createCall } from './call.js';
5
6
 
6
7
  const $ = (s, e = document) => e.querySelector(s);
7
8
  const $$ = (s, e = document) => [...e.querySelectorAll(s)];
@@ -23,11 +24,12 @@ const CHIP_ICON = {
23
24
  const svg = (d) => `<svg class="ico" viewBox="0 0 24 24" aria-hidden="true">${d}</svg>`;
24
25
  const ICON = {
25
26
  activity: '<path d="M3 12h4l3-7 4 14 3-7h4"/>',
26
- jobs: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8 9h8M8 13h5M8 17h3"/>',
27
+ jobs: '<rect x="4" y="5" width="16" height="15" rx="3"/><path d="M8 3v4M16 3v4M4 10h16m-12 5 2 2 5-4"/>',
27
28
  screen: '<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M9 20h6M12 16v4"/><circle cx="12" cy="10" r="2"/>',
28
29
  panic: '<circle cx="12" cy="12" r="9"/><path d="M8.5 8.5l7 7M15.5 8.5l-7 7"/>',
29
- notes: '<path d="M6 4h12v16H6z"/><path d="M9 9h6M9 13h6M9 17h3"/>',
30
- memory: '<path d="M6 4h9l3 3v13H6z"/><path d="M9 11h6M9 15h6M14 4v4h4"/>',
30
+ call: '<path d="M6.6 3.5h2.6l1.4 4-2 1.3a12 12 0 0 0 6.6 6.6l1.3-2 4 1.4v2.6a2 2 0 0 1-2.2 2A17 17 0 0 1 4.6 5.7a2 2 0 0 1 2-2.2z"/>',
31
+ notes: '<path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M9 15l1-4 8-8 3 3-8 8-4 1z"/>',
32
+ memory: '<path d="M12 5a3 3 0 0 0-5-2 4 4 0 0 0-3 6 4 4 0 0 0 0 7 4 4 0 0 0 8 3V5zm0 0a3 3 0 0 1 5-2 4 4 0 0 1 3 6 4 4 0 0 1 0 7 4 4 0 0 1-8 3M7 8l2 2M17 8l-2 2M6 15l3-1M18 15l-3-1"/>',
31
33
  sessions: '<path d="M4 6h16M4 12h16M4 18h10"/>',
32
34
  approvals: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
33
35
  phone: '<rect x="6" y="3" width="12" height="18" rx="3"/><path d="M10.5 17.5h3"/>',
@@ -211,14 +213,14 @@ async function api(path, opts = {}) {
211
213
  return data;
212
214
  }
213
215
 
214
- let streamCtrl = null, retry = 0, gotHello = false;
216
+ let streamCtrl = null, retry = 0, gotHello = false, alive = 0; // alive: última vegada que ha arribat alguna cosa de l'ordinador
215
217
  async function pollLoop(ctrl) {
216
218
  let first = true;
217
219
  while (!ctrl.signal.aborted) {
218
220
  try {
219
221
  const [list, aps] = await Promise.all([api('/api/sessions'), api('/api/approvals')]);
220
222
  if (ctrl.signal.aborted) return;
221
- state.connected = true; state.lastError = null;
223
+ state.connected = true; state.lastError = null; alive = Date.now();
222
224
  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(); }
223
225
  else {
224
226
  const r = state.route, seen = new Set();
@@ -295,7 +297,7 @@ async function connect() {
295
297
  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' });
296
298
  if (res.status === 401) throw Object.assign(new Error('La clau no és vàlida. Torna a connectar amb el codi QR.'), { fatal: true });
297
299
  if (!res.ok || !res.body) throw new Error(`Error ${res.status}`);
298
- state.connected = true; state.lastError = null; retry = 0;
300
+ state.connected = true; state.lastError = null; retry = 0; alive = Date.now();
299
301
  rememberIdentity(m.url, state.info.identity);
300
302
  renameMachine(state.info.host, state.info.id); syncServiceWorker(); render(); brainsForBoard(); loadVoice().then(() => state.route.name === 'session' && renderComposer?.()); flushQueue();
301
303
  const reader = res.body.getReader(), dec = new TextDecoder(); let buf = '';
@@ -304,6 +306,7 @@ async function connect() {
304
306
  const hw = setTimeout(() => { if (!gotHello) { poll = true; reader.cancel().catch(() => {}); } }, 5000);
305
307
  for (;;) {
306
308
  const { value, done } = await reader.read().catch(() => ({ done: true })); if (done) break;
309
+ alive = Date.now();
307
310
  buf += dec.decode(value, { stream: true }); let i;
308
311
  while ((i = buf.indexOf('\n\n')) >= 0) {
309
312
  const block = buf.slice(0, i); buf = buf.slice(i + 2);
@@ -363,6 +366,7 @@ function renameMachine(host, id) {
363
366
 
364
367
  function handle(msg) {
365
368
  const r = state.route;
369
+ callCtl?.onEvent(msg);
366
370
  switch (msg.type) {
367
371
  case 'device':
368
372
  if (!isPhone() && msg.device !== deviceId()) { if ($('#qrBox')) closeSheet(); toast('Mòbil connectat correctament', 2000); }
@@ -510,18 +514,19 @@ function checkPairHash() {
510
514
  /* ---------------------------------------------------------------- navegació */
511
515
  const VIEWS = {
512
516
  sessions: { label: 'Sessions', icon: ICON.sessions, group: 'Feina' },
513
- approvals: { label: 'Aprovacions', icon: ICON.approvals, group: 'Feina' },
514
- notes: { label: 'Notes', icon: ICON.notes, group: 'Feina' },
515
517
  feines: { label: 'Feines', icon: ICON.jobs, group: 'Feina' },
516
- memoria: { label: 'Memòria', icon: ICON.memory, group: 'Ordinadors' },
517
- ordinadors: { label: 'Ordinadors', icon: ICON.machine, group: 'Ordinadors' },
518
- mobil: { label: 'Xat local', icon: ICON.phone, group: 'Eines' },
519
- pissarra: { label: 'Pissarra', icon: ICON.board, group: 'Eines' },
520
- activitat: { label: 'Activitat', icon: ICON.activity, group: 'Compte' },
521
- ajustos: { label: 'Ajustos', icon: ICON.settings, group: 'Compte' },
522
- ajuda: { label: 'Ajuda', icon: ICON.help, group: 'Compte' }
518
+ approvals: { label: 'Aprovacions', icon: ICON.approvals, group: 'Feina' },
519
+ trucada: { label: 'Trucada', icon: ICON.call, group: 'Feina' },
520
+ notes: { label: 'Notes', icon: ICON.notes, group: 'Organització' },
521
+ memoria: { label: 'Memòria', icon: ICON.memory, group: 'Organització' },
522
+ pissarra: { label: 'Pissarra', icon: ICON.board, group: 'Organització' },
523
+ ordinadors: { label: 'Ordinadors', icon: ICON.machine, group: 'Ordinadors i eines' },
524
+ mobil: { label: 'Xat local', icon: ICON.phone, group: 'Ordinadors i eines' },
525
+ activitat: { label: 'Activitat', icon: ICON.activity, group: 'Ordinadors i eines' },
526
+ ajustos: { label: 'Ajustos', icon: ICON.settings, group: 'Preferències' },
527
+ ajuda: { label: 'Ajuda', icon: ICON.help, group: 'Preferències' }
523
528
  };
524
- const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', notes: '#/notes', feines: '#/feines', activitat: '#/activitat', memoria: '#/memoria', ordinadors: '#/ordinadors', mobil: '#/xat-local', pissarra: '#/pissarra', ajustos: '#/ajustos', ajuda: '#/ajuda' };
529
+ const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', trucada: '#/trucada', notes: '#/notes', feines: '#/feines', activitat: '#/activitat', memoria: '#/memoria', ordinadors: '#/ordinadors', mobil: '#/xat-local', pissarra: '#/pissarra', ajustos: '#/ajustos', ajuda: '#/ajuda' };
525
530
  const NEEDS_MACHINE = new Set(['session']);
526
531
  function parseRoute() {
527
532
  const h = location.hash.replace(/^#\/?/, '');
@@ -557,8 +562,8 @@ function renderChrome() {
557
562
  const m = machine(), sc = $('#sideConn');
558
563
  sc.innerHTML = m ? `<i class="dot ${state.connected ? 'on' : ''}"></i><span>${esc(m.name)}</span>${svg(ICON.chev)}` : `<i class="dot"></i><span>Afegeix un ordinador</span>${svg(ICON.plus)}`;
559
564
  sc.setAttribute('role', 'button'); sc.tabIndex = 0; sc.classList.add('switch'); sc.dataset.act = 'machines'; sc.title = 'Ordinadors';
560
- const more = ['notes', 'feines', 'memoria', 'approvals', 'mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].includes(r.name);
561
- $('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
565
+ const more = ['feines', 'trucada', 'memoria', 'approvals', 'mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].includes(r.name);
566
+ $('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['notes', ICON.notes, 'Notes', active === 'notes'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
562
567
  .map(([k, ic, l, on]) => `<a href="${HASH[k]}" ${on ? 'aria-current="page"' : ''}>${svg(ic)}<span>${l}</span>${k === 'approvals' && n ? `<b class="badge">${n}</b>` : ''}</a>`).join('')
563
568
  .replace('</a><a href="#/ordinadors"', `</a><button type="button" data-act="pantalla-tab">${svg('<path d="M5 3l14 7-6 2.2L10.8 19z"/>')}<span>Pantalla</span></button><a href="#/ordinadors"`)
564
569
  + `<button type="button" data-act="more" ${more ? 'aria-current="page"' : ''}>${svg(ICON.more)}<span>Més</span></button>`;
@@ -571,8 +576,9 @@ function renderChrome() {
571
576
  document.title = n ? `(${n}) nio` : `nio // ${s ? s.title || 'Sessió' : VIEWS[r.name]?.label || 'Panell'}`;
572
577
  if ('setAppBadge' in navigator) (n ? navigator.setAppBadge(n) : navigator.clearAppBadge()).catch?.(() => {});
573
578
  }
574
- let notesCtl = null;
575
- const getNotes = () => (notesCtl ||= createNotes({ api, nodeAudio, toast, esc, machine, voiceOn, voiceSheet, isDemo, openSheet, closeSheet, mobileQrSheet }));
579
+ let notesCtl = null, callCtl = null;
580
+ 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 }));
576
582
  function render() {
577
583
  const r = state.route, view = $('#view'), board = r.name === 'pissarra';
578
584
  if (r.name !== 'sessions') manage = null;
@@ -581,9 +587,11 @@ function render() {
581
587
  document.body.classList.toggle('chat-mode', r.name === 'session' || (r.name === 'mobil' && inLocalChat()));
582
588
  document.body.classList.toggle('no-tabs', r.name === 'session' || board || (r.name === 'mobil' && inLocalChat()));
583
589
  if (r.name !== 'notes' && lastName === 'notes') notesCtl?.unmount();
590
+ if (r.name !== 'trucada' && lastName === 'trucada') callCtl?.unmount();
584
591
  if (!board && lastName === 'pissarra') window.NioBoard?.unmount();
585
592
  if (board && lastName === 'pissarra' && $('#boardRoot')) return;
586
593
  if (r.name === 'notes' && lastName === 'notes' && $('#notesRoot') && machine()) return; // l'editor no es toca quan l'app es repinta
594
+ if (r.name === 'trucada' && lastName === 'trucada' && $('#callRoot')?.dataset.m === String(store.active)) return; // la pàgina de la trucada no es repinta a cada esdeveniment
587
595
  const changed = lastName !== r.name || r.name === 'session';
588
596
  lastName = r.name;
589
597
  if (r.name === 'session') renderSession();
@@ -591,12 +599,14 @@ function render() {
591
599
  view.innerHTML = machine() ? '<div class="page" id="notesRoot"></div>' : `<div class="page"><div class="top"><div><h1>Notes</h1><p>Escriu, grava i deixa que un model ho ordeni.</p></div></div>${noMachine('Les notes es desen a l’ordinador. Connecta’n un per escriure’n i gravar-ne.')}</div>`;
592
600
  if (machine()) getNotes().mount($('#notesRoot'));
593
601
  }
602
+ else if (r.name === 'trucada') { view.innerHTML = `<div class="page" id="callRoot" data-m="${store.active}"></div>`; getCall().mount($('#callRoot')); }
594
603
  else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
595
604
  else {
596
605
  const html = r.name === 'mobil' ? viewLocal() : null;
597
606
  view.innerHTML = html && inLocalChat() ? html : `<div class="page">${html ? html : r.name === 'ordinadors' ? viewComputers() : r.name === 'sessions' ? viewSessions() : r.name === 'approvals' ? viewApprovals() : r.name === 'activitat' ? viewActivity() : r.name === 'feines' ? viewJobs() : r.name === 'memoria' ? viewMemory() : r.name === 'ajuda' ? viewHelp() : viewSettings()}</div>`;
598
607
  if (r.name === 'mobil') bindLocal();
599
608
  if (r.name === 'mobil' && machine() && !inLocalChat()) loadOllama();
609
+ if (r.name === 'sessions' && machine() && state.connected) loadTermSessions();
600
610
  if (r.name === 'activitat' && machine()) { activity = null; loadActivity(); }
601
611
  if (r.name === 'memoria' && machine()) loadMemory();
602
612
  if (r.name === 'ordinadors' && machine() && state.connected) loadScreen();
@@ -611,7 +621,7 @@ function render() {
611
621
 
612
622
  /* ---------- Ordinadors (llista, afegir i detall de l'actiu) */
613
623
  const deviceId = () => { let d = LS.get('nio.device', ''); if (!d) { d = Math.random().toString(36).slice(2, 12); LS.set('nio.device', d); } return d; };
614
- const isPhone = () => isTouch || matchMedia('(max-width: 820px)').matches;
624
+ const isPhone = () => navigator.userAgentData?.mobile === true || /Android|iPhone|iPod|Mobile/i.test(navigator.userAgent) || (isTouch && matchMedia('(max-width: 1024px)').matches);
615
625
  const INSTALL_CMD = 'curl -fsSL https://www.nio.cat/install.sh | sh';
616
626
  const cmdBox = (c) => `<div class="cmd big"><code>${esc(c)}</code><button class="btn sm" data-copy="${esc(c)}" type="button">${svg(ICON.copy)}Copia</button></div>`;
617
627
  // Estat de la resta d'ordinadors (el connectat ja té el seu flux en directe): una consulta lleugera.
@@ -655,6 +665,7 @@ function helpPanel(open) {
655
665
 
656
666
  ${grp('Treballar amb els agents')}
657
667
  ${q('Crear una sessió', `<p>A <b>Sessions</b>, prem <b>Nova sessió</b>: tria l’agent, la carpeta (hi ha un explorador de carpetes de l’ordinador) i el nivell de permisos. Escriu què vols i l’agent es posa a treballar. La resposta et va arribant en directe.</p><p>També pots <b>clonar un repositori de GitHub</b> des de Nova sessió: nio fa servir el ${k('gh')} de l’ordinador (cal haver fet ${k('gh auth login')}).</p>`)}
668
+ ${q('Continuar una sessió del terminal', `<p>Si tens Claude Code obert al terminal de l’ordinador, surt a dalt de <b>Sessions</b>, a <b>Obertes al terminal</b>, amb el terminal on corre (iTerm, Terminal, VS Code, tmux, SSH…). Toca-la i continua aquí amb tota la conversa. El terminal no es toca: el mòbil en fa una branca pròpia.</p>`)}
658
669
  ${q('Els tres nivells de permisos', `<ul><li><b>Només llegir</b>: l’agent llegeix i proposa, però no toca cap fitxer.</li><li><b>Demana permís</b>: llegeix sol; abans d’editar o executar alguna cosa, t’ho pregunta (Claude Code).</li><li><b>Via lliure</b>: treballa sense preguntar. Fes-ho servir només en carpetes de confiança.</li></ul><p>Es pot canviar enmig d’una sessió amb ${k('/mode')}.</p>`)}
659
670
  ${q('Aprovacions', `<p>Quan un agent vol editar un fitxer o executar una ordre, surt una targeta amb el que farà. <b>Aprova</b> o <b>rebutja</b> amb un toc. A <b>Aprovacions</b> tens totes les pendents de totes les sessions juntes.</p>`)}
660
671
  ${q('Comandes amb «/»', `<p>Al missatge d’una sessió, escriu ${k('/')} i surt la llista amb suggeriments. Les de nio funcionen sempre; les de l’agent (comandes i skills) se li envien tal qual.</p>${rows([
@@ -671,6 +682,7 @@ function helpPanel(open) {
671
682
  ${q('Git i GitHub', `<p>Dins una sessió, ${k('/git')} (o Opcions → Git i GitHub) mostra els canvis, fa <b>commit</b> (en una branca nova si ets a ${k('main')}) i crea un <b>PR</b>. Cap clau no passa per nio: fa servir el ${k('git')} i el ${k('gh')} de l’ordinador.</p>`)}
672
683
  ${q('Avisos al mòbil', `<p>A <b>Ajustos → Avisos</b>, prem <b>Activa els avisos</b>. Rebràs una notificació quan un agent et demana permís o acaba la feina. L’avís arriba buit; el detall el demana el mòbil directament a l’ordinador.</p><p>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>`)}
673
684
  ${q('Veu', `<p>A <b>Ajustos → Veu</b> actives <b>Whisper</b> per <b>dictar</b> els missatges amb el micròfon, gratis i sense que l’àudio surti de l’ordinador. Per <b>escoltar</b> les respostes, hi pots posar una clau d’ElevenLabs o OpenAI, que es queda a l’ordinador.</p>`)}
685
+ ${q('Trucada amb en Nio', `<p>A <b>Més → Trucada</b> parles amb en Nio com en una trucada, amb veu en temps real d’ElevenLabs. Et diu quins agents tens, com van les sessions i què espera el teu permís, i li pots demanar que encarregui feina, continuï una sessió o aprovi una acció (abans t’ho repeteix i espera un sí). Si una sessió acaba durant la trucada, t’ho diu.</p><ul><li>Cal una clau d’ElevenLabs a <b>Ajustos → Veu</b>. Es queda a l’ordinador: el mòbil només rep un enllaç que caduca.</li><li>La primera trucada crea l’agent «nio» al teu compte d’ElevenLabs. ElevenLabs cobra per minut.</li><li>Pots interrompre’l parlant, silenciar el micròfon i veure tota la conversa amb <b>Text</b>.</li></ul>`)}
674
686
 
675
687
  ${grp('Eines')}
676
688
  ${q('Pantalla remota', `<p>Mira i controla l’escriptori d’un ordinador des del mòbil. A <b>Ordinadors</b>, baixa fins a la secció <b>Pantalla</b> i prem <b>Obre la pantalla</b>. S’obre a pantalla completa. Cal activar abans <b>Apps de l’escriptori</b> a la mateixa secció.</p><ul><li><b>Mirar</b>: només veus la pantalla, en directe. Amb un dit la mous quan has fet zoom.</li><li><b>Controlar</b>: toc = clic; doble toc = doble clic; mantenir premut = clic dret; dos dits = desplaçar o fer zoom. El botó <b>1 dit</b> canvia entre desplaçar i arrossegar, i el botó <b>2 dits</b> entre desplaçar l’ordinador i fer zoom amb pessics. <b>Teclat</b> obre el teclat del mòbil, i dalt hi tens ${k('⌘')} ${k('⌥')} ${k('⌃')} ${k('⇧')}, ${k('Esc')}, ${k('Tab')} i fletxes.</li><li><b>Zoom amb dos dits</b>: pessiga per apropar i arrossega amb dos dits per moure’t. Al mode Mirar sempre va així, amb doble toc per alternar entre enquadrar i apropar. Al mode Controlar, tria al botó <b>2 dits</b> si els dos dits desplacen l’ordinador o fan zoom. A l’ordinador, pessiga al trackpad o ${k('Ctrl')} + roda. Els botons <b>− 100% +</b> i <b>1:1</b> fan el mateix i funcionen en tots dos modes.</li><li><b>Desa captura</b>: baixa el fotograma actual com a imatge. <b>Captura</b> (a la secció) en fa una de sola, sense obrir la pantalla.</li><li><b>Si surt negra</b>: al Mac, dona el permís de <b>Gravació de pantalla</b> (i <b>Accessibilitat</b> per controlar) al Terminal on corre <span class="mono">nio-cat</span> i reinicia’l. A Linux, per controlar cal <span class="mono">xdotool</span>.</li></ul>`)}
@@ -835,7 +847,7 @@ function addComputerSheet(tab, opts = {}) {
835
847
  addMachine({ url: r.url, token: r.token, name: keepName || r.host, kind: 'vps', via: target });
836
848
  if (keepName) { const ms = store.machines; ms[store.active].renamed = true; store.machines = ms; }
837
849
  shown = 'vps-ok';
838
- body.innerHTML = `${intro(`${esc(r.host)} és a punt`, r.claude ? 'Ja el pots fer servir: crea-hi una sessió amb Claude Code.' : 'Claude Code no hi és. Entra al servidor, instal·la’l i inicia sessió amb <span class="mono">claude</span>.')}<div class="row"><a class="btn ink" href="#/sessions" data-close>Obre sessions</a><button class="btn" type="button" data-act="mobile-qr">Continua al mòbil</button></div>`;
850
+ body.innerHTML = `${intro(`${esc(r.host)} és a punt`, r.claude ? 'Ja el pots fer servir: crea-hi una sessió amb Claude Code.' : 'Claude Code no hi és. Entra al servidor, instal·la’l i inicia sessió amb <span class="mono">claude</span>.')}<div class="row"><a class="btn ink" href="#/sessions" data-close>Obre sessions</a>${isPhone() ? '' : '<button class="btn" type="button" data-act="mobile-qr">Continua al mòbil</button>'}</div>`;
839
851
  } catch (err) { $$('li.on', steps).forEach((l) => { l.className = 'err'; }); steps.insertAdjacentHTML('beforeend', `<li class="err">${esc(err.message)}</li>`); btn.disabled = false; shown = 'vps'; }
840
852
  };
841
853
  const vps = () => {
@@ -911,8 +923,9 @@ async function pantallaTab() {
911
923
  });
912
924
  }
913
925
  function moreSheet() {
914
- const item = (k) => `<a class="card row" href="${HASH[k]}" style="gap:12px">${svg(VIEWS[k].icon)}<b>${VIEWS[k].label}</b></a>`;
915
- openSheet(`<h2>Més</h2><div class="list">${['notes', 'feines', 'memoria', 'mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].map(item).join('')}</div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
926
+ const item = (k) => `<a class="more-link" href="${HASH[k]}" ${state.route.name === k ? 'aria-current="page"' : ''}>${svg(VIEWS[k].icon)}<span>${VIEWS[k].label}</span>${k === 'approvals' && state.approvals.size ? `<span class="badge">${state.approvals.size}</span>` : ''}</a>`;
927
+ const groups = [...new Set(Object.values(VIEWS).map((v) => v.group))].map((group) => [group, Object.keys(VIEWS).filter((k) => VIEWS[k].group === group)]);
928
+ openSheet(`<h2>Més</h2><nav class="more-sections" aria-label="Totes les seccions">${groups.map(([title, keys]) => `<section><h3>${title}</h3><div>${keys.map(item).join('')}</div></section>`).join('')}</nav>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
916
929
  }
917
930
 
918
931
  /* ---------- Sessions */
@@ -944,6 +957,46 @@ async function manageDelete() {
944
957
  toast(fail ? `No he pogut esborrar ${fail} sessions` : `${ids.length} sessions esborrades`); render();
945
958
  }
946
959
  const plain = (t) => `${t}`.replace(/```[\s\S]*?```/g, ' ').replace(/`([^`]*)`/g, '$1').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/^\s{0,3}(#{1,6}|[-*+]|\d+\.|>)\s+/gm, '').replace(/(\*\*|__|\*|_)(?=\S)([^*_]*?\S)\1/g, '$2').replace(/\*\*/g, '').replace(/\s#{1,6}\s/g, ' ').replace(/\s#{1,6}\s/g, ' ').replace(/\s+/g, ' ').trim();
960
+ // Sessions de Claude Code obertes al terminal de l'ordinador: es poden continuar des d'aquí.
961
+ let termSessions = null, termTimer = 0, termAt = 0, termFor = '';
962
+ async function loadTermSessions(force) {
963
+ if (!machine() || !state.connected || state.route.name !== 'sessions') return;
964
+ const key = machine().url;
965
+ if (termFor !== key) { termSessions = null; termFor = key; }
966
+ // render() es crida sovint: només es torna a demanar si fa una estona.
967
+ if (!force && Date.now() - termAt < 4000) return;
968
+ clearTimeout(termTimer); termAt = Date.now();
969
+ try { const r = (await api('/api/claude-sessions')).sessions || []; if (termFor === key) termSessions = r; } catch { termSessions = termSessions || []; }
970
+ const box = $('#termSessions'); if (box && state.route.name === 'sessions') box.innerHTML = termSessionsHtml();
971
+ // Es manté al dia mentre la pàgina és oberta: el terminal canvia sense avisar.
972
+ if (!document.hidden) termTimer = setTimeout(() => loadTermSessions(true), 8000);
973
+ }
974
+ document.addEventListener('visibilitychange', () => { if (!document.hidden && state.route.name === 'sessions') loadTermSessions(true); });
975
+ const TERM_STATUS = { busy: ['Treballant', 'running'], idle: ['Esperant', ''], waiting: ['Et necessita', 'waiting'], needs_input: ['Et necessita', 'waiting'] };
976
+ function termSessionsHtml() {
977
+ if (!termSessions?.length) return '';
978
+ const taken = new Set([...state.sessions.values()].flatMap((s) => [s.nativeId, s.fromTerminal]).filter(Boolean));
979
+ const fresh = termSessions.filter((s) => !taken.has(s.sessionId));
980
+ if (!fresh.length) return '';
981
+ const where = (t) => [t?.app, t?.tmux && 'tmux', t?.screen && 'screen', t?.ssh && 'SSH'].filter(Boolean).join(' · ');
982
+ return `<section class="term-sessions" aria-label="Al terminal"><h2 class="sec">Obertes al terminal</h2><div class="list">${fresh.map((s) => {
983
+ const [label, cls] = TERM_STATUS[s.status] || [s.status, ''];
984
+ return `<button class="card session-card term-card" type="button" data-act="resume" data-sid="${esc(s.sessionId)}" data-cwd="${esc(s.cwd || '')}" data-sname="${esc(s.title || s.name || '')}">
985
+ <div class="row between"><span class="agent-tag nosq" data-agent="claude">${agentBot('claude', s.status === 'busy' ? 'work' : 'idle')}${esc(where(s.terminal) || 'Terminal')}</span><span class="chip ${cls}">${cls ? '<i class="pulse"></i>' : ''}${esc(label)}</span></div>
986
+ <div class="title">${esc(s.title || s.name || basename(s.cwd || '') || 'Claude Code')}</div>
987
+ ${s.ask ? `<div class="last">${esc(plain(s.ask))}</div>` : ''}
988
+ <div class="meta"><span class="mono">${esc(basename(s.cwd || ''))}</span>${s.updated ? `<span>·</span><span>${ago(s.updated)}</span>` : ''}<span class="term-go">Continua aquí →</span></div>
989
+ </button>`;
990
+ }).join('')}</div></section>`;
991
+ }
992
+ function sessionCard(s) {
993
+ return `<${manage ? 'div role="checkbox" tabindex="0" aria-checked="' + manage.has(s.id) + '" data-pick="' + esc(s.id) + '"' : 'a href="#/s/' + esc(s.id) + '"'} class="card session-card ${manage ? 'pick-mode' : ''} ${manage?.has(s.id) ? 'picked' : ''}">
994
+ <div class="row between"><span class="agent-tag nosq" data-agent="${esc(s.agent)}">${agentBot(s.agent, sessionMood(s))}${esc(AGENT_NAMES[s.agent] || s.agent)}</span>${stateChip(s)}</div>
995
+ <div class="title">${esc(s.title || 'Sessió nova')}</div>
996
+ ${s.state === 'error' && s.error ? `<div class="last err">${esc(s.error)}</div>` : s.last ? `<div class="last">${esc(plain(s.last))}</div>` : ''}
997
+ <div class="meta"><span class="mono">${esc(basename(s.cwd))}</span><span>·</span><span>${esc(MODES[s.mode]?.label || '')}</span><span>·</span><span>${ago(s.updated)}</span></div>
998
+ </${manage ? 'div' : 'a'}>`;
999
+ }
947
1000
  function viewSessions() {
948
1001
  if (!machine()) return `<div class="top"><div><h1>Sessions</h1><p>Cada sessió és una conversa amb un agent, dins d’una carpeta.</p></div></div>${noMachine('Connecta’n un per donar feina als agents i seguir-ne les sessions en directe.')}`;
949
1002
  const list = [...state.sessions.values()].sort((a, b) => b.updated - a.updated);
@@ -956,13 +1009,11 @@ function viewSessions() {
956
1009
  return `${head}${queueList()}<div class="panel empty">${bot(state.lastError ? 'sleep' : 'work')}<h2>${esc(state.lastError || 'Connectant amb l’ordinador…')}</h2>${state.lastError ? `<p>${mach.kind === 'vps' ? 'Potser el servidor s’ha reiniciat i l’adreça ha canviat.' : 'Comprova que nio continua engegat a l’ordinador i que el túnel està obert.'}</p><div class="row" style="justify-content:center"><button class="btn" data-act="retry" type="button">${svg(ICON.refresh)}Torna-ho a provar</button>${mach.kind === 'vps' && mach.via ? `<button class="btn ink" data-reconnect="${store.active}" type="button">Torna a connectar</button>` : ''}<button class="btn ghost" data-act="new" type="button">Posa una tasca a la cua</button><a class="btn ghost" href="#/ordinadors">Ordinadors</a></div>` : ''}</div>`;
957
1010
  }
958
1011
  if (manage) for (const id of [...manage]) if (!state.sessions.has(id)) manage.delete(id);
959
- return `${head}${queueList()}${list.length ? `<div class="list">${list.map((s) => `
960
- <${manage ? 'div role="checkbox" tabindex="0" aria-checked="' + manage.has(s.id) + '" data-pick="' + esc(s.id) + '"' : 'a href="#/s/' + esc(s.id) + '"'} class="card session-card ${manage ? 'pick-mode' : ''} ${manage?.has(s.id) ? 'picked' : ''}">
961
- <div class="row between"><span class="agent-tag nosq" data-agent="${esc(s.agent)}">${agentBot(s.agent, sessionMood(s))}${esc(AGENT_NAMES[s.agent] || s.agent)}</span>${stateChip(s)}</div>
962
- <div class="title">${esc(s.title || 'Sessió nova')}</div>
963
- ${s.state === 'error' && s.error ? `<div class="last err">${esc(s.error)}</div>` : s.last ? `<div class="last">${esc(plain(s.last))}</div>` : ''}
964
- <div class="meta"><span class="mono">${esc(basename(s.cwd))}</span><span>·</span><span>${esc(MODES[s.mode]?.label || '')}</span><span>·</span><span>${ago(s.updated)}</span></div>
965
- </${manage ? 'div' : 'a'}>`).join('')}</div>` : `<div class="panel empty">${bot('idle')}<h2>Encara no hi ha cap sessió</h2><p>Tria un agent i una carpeta, i explica-li què ha de fer.</p><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>`}`;
1012
+ const now = Date.now(), recent = list.filter((s) => now - s.updated < DAY || s.state === 'running' || s.state === 'waiting' || s.pending), old = list.filter((s) => !recent.includes(s));
1013
+ const recentHtml = recent.length ? `<div class="list">${recent.map(sessionCard).join('')}</div>` : '';
1014
+ const oldHtml = old.length ? `<details class="old-sessions"><summary class="muted small">${old.length} sessi${old.length > 1 ? 'ons' : 'o'} anterior${old.length > 1 ? 's' : ''}</summary><div class="list">${old.map(sessionCard).join('')}</div></details>` : '';
1015
+ const termHtml = `<div id="termSessions">${termSessionsHtml()}</div>`;
1016
+ return `${head}${queueList()}${termHtml}${list.length ? `${recentHtml}${oldHtml}` : `<div class="panel empty">${bot('idle')}<h2>Encara no hi ha cap sessió</h2><p>Tria un agent i una carpeta, i explica-li què ha de fer.</p><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>`}`;
966
1017
  }
967
1018
 
968
1019
  /* ---------- Aprovacions */
@@ -1021,7 +1072,7 @@ async function loadActivity() {
1021
1072
  let mem = null;
1022
1073
  function viewMemory() {
1023
1074
  if (!machine()) return `<div class="top"><div><h1>Memòria</h1><p>El que recorden els teus agents: preferències, decisions i context dels projectes.</p></div></div>${noMachine('La memòria són fitxers a l’ordinador. Connecta’n un per veure-la i editar-la.')}`;
1024
- return `<div class="top"><div><h1>Memòria</h1><p>El que recorden els teus agents en aquest ordinador. Claude Code i Codex la llegeixen i hi escriuen (servidor MCP de nio). Són fitxers markdown a <span class="mono">~/.nio/memory</span>.</p></div><button class="btn ink" data-act="mem-new" type="button">${svg(ICON.plus)}Fitxer nou</button></div>
1075
+ return `<div class="top"><div><h1>Memòria</h1><p>Preferències, decisions i context que els agents recorden en aquest ordinador. Obre un fitxer per consultar-lo o editar-lo.</p></div><button class="btn ink" data-act="mem-new" type="button">${svg(ICON.plus)}Fitxer nou</button></div>
1025
1076
  <form class="mem-search" id="memSearch" role="search"><input type="search" id="memQ" placeholder="Busca a la memòria…" aria-label="Busca a la memòria" autocomplete="off"><button class="btn ink" type="submit">Busca</button></form>
1026
1077
  <div id="memBody">${memHtml()}</div>`;
1027
1078
  }
@@ -1040,14 +1091,18 @@ async function loadMemory() {
1040
1091
  async function memEdit(p) {
1041
1092
  let f = { path: p || 'projectes/', content: '' };
1042
1093
  if (p) { try { f = await api(`/api/memory/f/${encodeURIComponent(p)}`); } catch (e) { return toast(e.message); } }
1043
- openSheet(`<h2>${p ? esc(p) : 'Fitxer nou'}</h2>
1044
- ${p ? '' : '<label class="f"><span>Nom (acaba en .md)</span><input type="text" id="memPath" value="projectes/" autocapitalize="off" spellcheck="false"></label>'}
1094
+ openSheet(`<h2>${p ? esc(p) : 'Fitxer nou'}</h2><form class="stack" id="memForm">
1095
+ ${p ? '' : '<label class="f"><span>Nom (acaba en .md)</span><input type="text" id="memPath" value="projectes/" placeholder="projectes/context.md" required pattern=".+\\.md" title="Escriu un nom acabat en .md, per exemple projectes/context.md" autocapitalize="off" spellcheck="false"></label>'}
1045
1096
  <label class="f"><span>Contingut</span><textarea class="field mono" id="memText" rows="14" spellcheck="false">${esc(f.content)}</textarea></label>
1046
- <div class="row between sheet-foot">${p ? '<button class="btn danger" type="button" id="memDel">Esborra</button>' : '<span></span>'}<button class="btn ink" type="button" id="memSave">Desa</button></div>`, (sh) => {
1047
- $('#memSave', sh).onclick = async () => {
1097
+ <p id="memError" class="form-error" role="alert" hidden></p>
1098
+ <div class="row between sheet-foot">${p ? '<button class="btn danger" type="button" id="memDel">Esborra</button>' : '<span></span>'}<button class="btn ink" type="submit" id="memSave">Desa</button></div></form>`, (sh) => {
1099
+ $('#memForm', sh).onsubmit = async (event) => {
1100
+ event.preventDefault();
1048
1101
  const path = p || $('#memPath', sh).value.trim();
1102
+ const button = $('#memSave', sh), error = $('#memError', sh); button.disabled = true; button.textContent = 'Desant…'; error.hidden = true;
1049
1103
  try { await api(`/api/memory/f/${encodeURIComponent(path)}`, { method: 'PUT', body: { content: $('#memText', sh).value } }); toast('Desat'); closeSheet(); mem = null; loadMemory(); }
1050
- catch (e) { toast(e.message, 4200); }
1104
+ catch (e) { error.textContent = `${e.message} Pots tornar a prémer «Desa».`; error.hidden = false; }
1105
+ finally { button.disabled = false; button.textContent = 'Desa'; }
1051
1106
  };
1052
1107
  if (p) $('#memDel', sh).onclick = async () => {
1053
1108
  if (!confirm(`S’esborrarà «${p}» de la memòria. Els agents ja no ho recordaran.`)) return;
@@ -1256,7 +1311,10 @@ const hhmm = (t) => new Date(t * 1000).toLocaleString('ca', { weekday: t * 1000
1256
1311
  function usageChip(agent) {
1257
1312
  const u = state.usage?.[agent]; if (!u?.windows?.length) return '';
1258
1313
  const tip = u.windows.map((w) => `${w.label}: ${Math.round(w.used * 100)} %${w.resetsAt ? ` · es reinicia ${hhmm(w.resetsAt)}` : ''}`).join('\n');
1259
- return `<span class="chip mono usage" title="${esc(`Ús de la subscripció\n${tip}`)}">${svg(CHIP_ICON.usage)}${u.windows.map((w) => `<span class="uw" data-hi="${w.used >= 0.8 ? 1 : 0}">${esc(w.label)} ${Math.round(w.used * 100)}%</span>`).join('')}</span>`;
1314
+ // Etiquetes curtes: «5h», «7d». La icona és un anell que s'omple amb la finestra més plena.
1315
+ const short = (l) => (l === 'setmana' ? '7d' : l.replace(/\s+/g, '')), top = Math.max(...u.windows.map((w) => w.used)), pct = Math.round(top * 100);
1316
+ 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>`;
1317
+ 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>`;
1260
1318
  }
1261
1319
  function renderSessionHead() {
1262
1320
  renderChrome();
@@ -1369,6 +1427,7 @@ function putEvent(e, scroll = true) {
1369
1427
  const feed = $('#feed'); if (!feed) return;
1370
1428
  if (e.kind === 'text' && scroll && (e.open || typers.has(e.seq))) return streamText(state.route.id, e);
1371
1429
  const stick = nearBottom(feed);
1430
+ if (e.kind === 'tool_result' && TITLE_TOOL.test(e.name || '')) return; // el títol ja es veu a dalt i a la línia «Títol»
1372
1431
  if (e.kind === 'tool_result') {
1373
1432
  const t = toolEls.get(e.id);
1374
1433
  if (t) {
@@ -1394,12 +1453,15 @@ function putEvent(e, scroll = true) {
1394
1453
  if (scroll && e.kind === 'user' && pinNext && !old) { pinNext = false; return pinMessage(feed, node); }
1395
1454
  if (scroll) follow(feed, stick);
1396
1455
  }
1456
+ // L'agent posa títol a la sessió amb l'eina sessio_titol de nio (Claude: mcp__nio__sessio_titol, Codex: nio.sessio_titol).
1457
+ const TITLE_TOOL = /(^|[._])sessio_titol$/;
1397
1458
  function eventNode(e) {
1398
1459
  const s = state.sessions.get(state.route.id);
1399
1460
  switch (e.kind) {
1400
1461
  case 'user': return el(`<div class="msg-user">${esc(e.text)}</div>`);
1401
- case 'text': return el(`<div class="msg-ai ${e.open && s?.state === 'running' ? 'cursor' : ''}" data-seq="${e.seq}">${md(e.text)}${!e.open && voiceOn() ? `<button class="speak" type="button" data-speak aria-label="Escolta la resposta">${svg(ICON.speaker)}<span>Escolta</span></button>` : ''}</div>`);
1402
- case 'tool': return el(`<details class="tool"><summary><span class="name">${esc(e.name)}</span><span class="arg">${esc(toolArg(e.input))}</span><span class="st">…</span></summary><pre>${esc(prettyInput(e.input))}</pre></details>`);
1462
+ case 'text': return el(`<div class="msg-ai ${e.open && s?.state === 'running' ? 'cursor' : ''}" data-seq="${e.seq}">${md(e.text)}${!e.open && ttsOn() ? `<button class="speak" type="button" data-speak aria-label="Escolta la resposta">${svg(ICON.speaker)}<span>Escolta</span></button>` : ''}</div>`);
1463
+ case 'tool': if (TITLE_TOOL.test(e.name || '')) return el(`<div class="ev-info">Títol: «${esc(e.input?.titol || '')}»</div>`);
1464
+ return el(`<details class="tool"><summary><span class="name">${esc(e.name)}</span><span class="arg">${esc(toolArg(e.input))}</span><span class="st">…</span></summary><pre>${esc(prettyInput(e.input))}</pre></details>`);
1403
1465
  case 'tool_result': return el(`<details class="tool"><summary><span class="name">${esc(e.name || 'Eina')}</span><span class="arg"></span><span class="st ${e.error ? 'err' : 'ok'}">${e.error ? '✕' : '✓'}</span></summary><pre class="out">${esc(e.output)}</pre></details>`);
1404
1466
  case 'approval': { const a = state.approvals.get(e.approval) || {}; return el(approvalCard({ ...a, approval: e.approval, tool: e.tool, input: e.input, state: e.state && e.state !== 'pending' ? e.state : a.state || e.state }, false)); }
1405
1467
  case 'error': return el(`<div class="ev-error" role="alert"><b>Error</b><span>${esc(e.text)}</span></div>`);
@@ -1576,10 +1638,11 @@ function renderComposer() {
1576
1638
 
1577
1639
  /* ---------- Fulls */
1578
1640
  function openSheet(html, onMount, cls) {
1579
- const d = $('#sheet'); d.classList.toggle('wiz', !!cls?.startsWith('wiz')); d.classList.toggle('compact', cls === 'wiz compact'); const si = $('#sheetIn'); si.onclick = si.onsubmit = si.oninput = si.onchange = null; si.innerHTML = cls?.startsWith('wiz') ? '' : `<div class="grab"></div>${html}`;
1641
+ const d = $('#sheet'); d.classList.toggle('wiz', !!cls?.startsWith('wiz')); d.classList.toggle('compact', cls === 'wiz compact'); const si = $('#sheetIn'); si.onclick = si.onsubmit = si.oninput = si.onchange = null; si.innerHTML = cls?.startsWith('wiz') ? '' : `<div class="grab"></div><button class="icon-btn sheet-close" type="button" data-sheet-close aria-label="Tanca">${svg(ICON.close)}</button>${html}`;
1580
1642
  if (!d.open) d.showModal(); raiseToasts();
1581
1643
  if (isTouch) { if (document.activeElement?.matches?.('input, textarea, select')) document.activeElement.blur(); } else setTimeout(() => focusIn(d), 30); if (T.on) $('#tour').hidden = true;
1582
1644
  onMount?.($('#sheetIn'));
1645
+ const title = si.querySelector('h2'); if (title) { title.id = 'sheetTitle'; d.setAttribute('aria-labelledby', title.id); } else { d.removeAttribute('aria-labelledby'); d.setAttribute('aria-label', 'Opcions'); }
1583
1646
  }
1584
1647
  const closeSheet = () => { const d = $('#sheet'); if (d.open) d.close(); setTimeout(focusMain, 0); };
1585
1648
  // A l'ordinador el cursor és sempre on s'escriu; al mòbil no, perquè no s'obri el teclat.
@@ -1590,9 +1653,50 @@ function focusMain() {
1590
1653
  if (r === 'session') $('#msg')?.focus({ preventScroll: true });
1591
1654
  else if (r === 'mobil' && inLocalChat()) $('#lmsg')?.focus({ preventScroll: true });
1592
1655
  }
1593
- $('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet') closeSheet(); });
1656
+ $('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet' || e.target.closest('[data-sheet-close]')) closeSheet(); });
1594
1657
  $('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
1595
1658
 
1659
+ // Al mòbil, els fulls es tanquen lliscant cap avall, com els de l'iPhone: el full segueix el dit
1660
+ // 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');
1663
+ let g = null;
1664
+ 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
+ const reset = () => { d.style.transition = ''; d.style.transform = ''; };
1666
+ d.addEventListener('touchstart', (e) => {
1667
+ g = null;
1668
+ if (!mq.matches || e.touches.length !== 1 || e.target.closest('input, textarea, select, [contenteditable], input[type=range], [data-noswipe]')) return;
1669
+ const t = e.touches[0], sc = scrollerOf(e.target);
1670
+ const head = !!e.target.closest('.grab, .wiz-head, h2');
1671
+ if (sc && sc.scrollTop > 0 && !head) return;
1672
+ g = { y0: t.clientY, x0: t.clientX, dy: 0, lock: null, sc: head ? null : sc, pts: [[e.timeStamp, t.clientY]] };
1673
+ }, { passive: true });
1674
+ d.addEventListener('touchmove', (e) => {
1675
+ if (!g) return;
1676
+ const t = e.touches[0], dy = t.clientY - g.y0, dx = t.clientX - g.x0;
1677
+ if (!g.lock) {
1678
+ if (Math.abs(dy) < 6 && Math.abs(dx) < 6) return;
1679
+ if (dy > 0 && dy > Math.abs(dx) && !(g.sc && g.sc.scrollTop > 0)) { g.lock = 'y'; d.style.transition = 'none'; }
1680
+ else { g = null; return; }
1681
+ }
1682
+ if (e.cancelable) e.preventDefault();
1683
+ g.dy = Math.max(0, dy); g.pts.push([e.timeStamp, t.clientY]); if (g.pts.length > 5) g.pts.shift();
1684
+ d.style.transform = `translateY(${g.dy}px)`;
1685
+ }, { passive: false });
1686
+ const end = (e) => {
1687
+ const x = g; g = null; if (!x || x.lock !== 'y') return;
1688
+ const [t0, y0] = x.pts[0], [t1, y1] = x.pts[x.pts.length - 1], v = (y1 - y0) / Math.max(1, t1 - t0);
1689
+ 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; }
1691
+ 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);
1693
+ } else { d.style.transition = 'transform .3s cubic-bezier(.22,1,.36,1)'; d.style.transform = ''; setTimeout(reset, 300); }
1694
+ };
1695
+ d.addEventListener('touchend', end, { passive: true });
1696
+ d.addEventListener('touchcancel', end, { passive: true });
1697
+ d.addEventListener('close', reset);
1698
+ })();
1699
+
1596
1700
  // Claude Code no deixa «Via lliure» quan nio corre com a root (típic en un VPS).
1597
1701
  const modeBlocked = (agentId, mode) => agentId === 'claude' && mode === 'tot' && state.info?.user === 'root';
1598
1702
  function modeHelp(agentId, mode) {
@@ -2468,6 +2572,8 @@ document.addEventListener('click', async (e) => {
2468
2572
  // Les claus (ElevenLabs o OpenAI) viuen a l'ordinador. L'àudio fa: mòbil → ordinador → proveïdor.
2469
2573
  let nodeVoice = null; // estat de la veu de l'ordinador actiu (sense claus)
2470
2574
  const voiceOn = () => !!nodeVoice?.stt;
2575
+ // Escoltar necessita una clau d'ElevenLabs o OpenAI: amb Whisper sol no hi ha veu per parlar.
2576
+ const ttsOn = () => !!nodeVoice?.tts;
2471
2577
  async function loadVoice() { try { nodeVoice = await api('/api/voice'); } catch { nodeVoice = null; } }
2472
2578
  async function nodeAudio(path, opts) {
2473
2579
  const m = machine(); if (!m) throw new Error('No hi ha cap ordinador connectat');
@@ -2534,13 +2640,15 @@ async function voiceSheet() {
2534
2640
  openSheet(`<h2>Veu</h2><p class="muted small">Per dictar n’hi ha prou amb Whisper, gratis i a l’ordinador «${esc(machine().name)}». Les claus són opcionals: serveixen per escoltar les respostes en veu alta. Es desen a l’ordinador i no tornen mai al mòbil.</p>
2535
2641
  <div id="whisperBox" style="margin-top:14px">${whisperCard(v.whisper)}</div>
2536
2642
  <form class="stack" id="voiceForm" style="margin-top:14px">
2643
+ <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>
2537
2644
  <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>
2538
2645
  <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>
2539
2646
  <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>
2540
2647
  <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>
2541
2648
  <label class="row check"><input type="checkbox" id="vSpeak" ${v.speak ? 'checked' : ''}> <span>Llegeix en veu alta les respostes de la sessió oberta</span></label>
2542
2649
  ${old.key ? '<p class="muted small">Tenies una clau d’ElevenLabs desada en aquest navegador: la passo a l’ordinador i l’esborro d’aquí.</p>' : ''}
2543
- <div class="row between sheet-foot"><button class="btn ghost" type="button" id="vOff">Treu les claus</button><button class="btn ink" type="submit">Desa</button></div>
2650
+ <button class="btn ghost" type="button" id="vOff">Treu les claus</button></div></details>
2651
+ <div class="row sheet-foot"><button class="btn ink" type="submit">Desa</button></div>
2544
2652
  </form>`, (sh) => {
2545
2653
  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); } };
2546
2654
  $('#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'); };
@@ -2619,6 +2727,17 @@ document.addEventListener('click', async (e) => {
2619
2727
  if (a.dataset.qdrop) { queue.drop(a.dataset.qdrop); return render(); }
2620
2728
  switch (a.dataset.act) {
2621
2729
  case 'new': return newSessionSheet();
2730
+ case 'resume': {
2731
+ const btn = a; btn.disabled = true;
2732
+ const sid = btn.dataset.sid, cwd = btn.dataset.cwd, sname = btn.dataset.sname;
2733
+ try {
2734
+ const title = sname || basename(cwd) || 'Terminal';
2735
+ const ns = await api('/api/sessions', { method: 'POST', body: { agent: 'claude', cwd: cwd || '', mode: 'preguntar', resumeId: sid, title } });
2736
+ state.sessions.set(ns.id, ns); termSessions = termSessions?.filter((x) => x.sessionId !== sid) || null;
2737
+ go(`#/s/${ns.id}`);
2738
+ } catch (err) { toast(err.message, 4200); btn.disabled = false; }
2739
+ return;
2740
+ }
2622
2741
  case 'manage-on': manage = new Set(); return render();
2623
2742
  case 'manage-off': manage = null; return render();
2624
2743
  case 'manage-del': return manageDelete();
@@ -2903,7 +3022,19 @@ if ('serviceWorker' in navigator) {
2903
3022
  navigator.serviceWorker.addEventListener('message', (e) => { if (e.data?.type === 'open-session' && e.data.id) go(`#/s/${e.data.id}`); });
2904
3023
  }
2905
3024
  addEventListener('hashchange', route);
2906
- document.addEventListener('visibilitychange', () => { if (!document.hidden && machine() && !state.connected) connect(); });
3025
+ // 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
+ // 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.
3027
+ function revive(maxIdle) {
3028
+ if (!machine() || isDemo(machine()) || document.hidden) return;
3029
+ // Desconnectat, el vigilant no talla els reintents en curs; en tornar a l'app sí que es torna a provar de seguida.
3030
+ if (state.connected ? Date.now() - alive < maxIdle : maxIdle >= 40000) return;
3031
+ retry = 0; reconTry.clear(); connect();
3032
+ }
3033
+ document.addEventListener('visibilitychange', () => revive(20000));
3034
+ addEventListener('pageshow', (e) => { if (e.persisted) revive(0); });
3035
+ addEventListener('online', () => revive(0));
3036
+ addEventListener('focus', () => revive(20000));
3037
+ setInterval(() => revive(40000), 10000);
2907
3038
  setInterval(() => { if (!document.hidden && state.route.name === 'sessions') render(); }, 60000);
2908
3039
  const qs = new URLSearchParams(location.search).get('s');
2909
3040
  if (qs) history.replaceState(null, '', location.pathname + `#/s/${qs}`);
@@ -0,0 +1,80 @@
1
+ /* nio · Trucada */
2
+ .call-hero { display: grid; justify-items: center; gap: 10px; max-width: 640px; margin: 8px auto 0; padding: 40px 24px 32px; border: 1px solid var(--border); border-radius: 24px; background: var(--panel); text-align: center; }
3
+ .call-hero h2 { margin-top: 8px; font-size: 22px; font-weight: 500; letter-spacing: -.04em; }
4
+ .call-hero > p { max-width: 440px; text-wrap: balance; }
5
+ .call-why { font-size: 14px; color: var(--fg-muted); }
6
+ .call-why.err { color: var(--red); }
7
+ .call-cta { margin-top: 14px; }
8
+ .call-try { max-width: 640px; margin: 0 auto; }
9
+ .call-try .sec { font-size: 15px; margin: 28px 0 10px; }
10
+ .call-ex { display: flex; flex-wrap: wrap; gap: 8px; }
11
+ .call-ex span { padding: 7px 12px; border: 1px solid var(--border); border-radius: 999px; background: var(--white); font-size: 14px; }
12
+ .call-notes { margin: 18px 0 0; padding-left: 18px; display: grid; gap: 6px; }
13
+
14
+ /* La cara d'en Nio: anells que respiren i que creixen amb la veu (--lvl, de 0 a 1). */
15
+ .call-face { --lvl: 0; position: relative; display: grid; place-items: center; width: 132px; height: 132px; color: var(--fg); }
16
+ .call-face .call-bot { --s: 64px; position: relative; z-index: 2; }
17
+ .call-ring { position: absolute; inset: 0; border-radius: 50%; border: 1.5px solid currentColor; opacity: .18; transform: scale(calc(.82 + var(--lvl) * .3)); transition: transform .08s linear; animation: call-breathe 3.2s ease-in-out infinite; }
18
+ .call-ring.r2 { opacity: .09; transform: scale(calc(1 + var(--lvl) * .55)); animation-delay: -1.6s; }
19
+ .call-lvl { position: absolute; inset: 14%; border-radius: 50%; background: radial-gradient(circle, color-mix(in srgb, var(--accent) 70%, transparent), transparent 70%); opacity: calc(var(--lvl) * 1.4); transform: scale(calc(.9 + var(--lvl) * .5)); transition: transform .08s linear, opacity .08s linear; }
20
+ @keyframes call-breathe { 50% { opacity: .3; } }
21
+
22
+ .call-btn { display: inline-flex; align-items: center; justify-content: center; gap: 10px; border: 0; border-radius: 999px; color: #fff; cursor: pointer; transition: transform .16s var(--ease), filter .16s, box-shadow .2s; -webkit-tap-highlight-color: transparent; }
23
+ .call-btn .ico { width: 26px; height: 26px; fill: currentColor; stroke: none; }
24
+ .call-btn:active { transform: scale(.93); }
25
+ .call-btn:disabled { opacity: .55; cursor: progress; }
26
+ .call-btn.go { min-width: 72px; height: 72px; padding: 0 30px; background: #1f9d55; box-shadow: 0 12px 30px -12px #1f9d55; font-size: 17px; font-weight: 600; }
27
+ .call-btn.go:hover { filter: brightness(1.06); }
28
+ .call-btn.end { width: 76px; height: 76px; background: var(--red); box-shadow: 0 12px 30px -12px var(--red); }
29
+ .call-btn.end .ico { width: 32px; height: 32px; }
30
+
31
+ /* Pantalla de la trucada: fosca, com al telèfon, i la conversa com les lletres d'Apple Music. */
32
+ .call-ov { --lvl: 0; position: fixed; inset: 0; z-index: 90; display: grid; grid-template-rows: auto 1fr auto; color: #fff; background: #141112; overscroll-behavior: contain; overflow: hidden; }
33
+ .call-amb { position: absolute; inset: -20%; pointer-events: none; filter: blur(70px) saturate(1.2); opacity: calc(.5 + var(--lvl) * .45); transform: scale(calc(1 + var(--lvl) * .12)); transition: opacity .2s linear, transform .2s linear; }
34
+ .call-amb i { position: absolute; width: 62vmax; height: 62vmax; border-radius: 50%; animation: call-drift 22s ease-in-out infinite alternate; }
35
+ .call-amb i:nth-child(1) { left: -10%; top: -15%; background: radial-gradient(circle, #f46a3b 0%, transparent 62%); }
36
+ .call-amb i:nth-child(2) { right: -4%; top: 18%; background: radial-gradient(circle, #6d3f7e 0%, transparent 62%); animation-duration: 27s; animation-delay: -9s; }
37
+ .call-amb i:nth-child(3) { left: 10%; bottom: -30%; background: radial-gradient(circle, #1f5f63 0%, transparent 62%); animation-duration: 31s; animation-delay: -15s; }
38
+ @keyframes call-drift { 0% { transform: translate(0, 0) scale(1); } 50% { transform: translate(8vmax, 6vmax) scale(1.12); } 100% { transform: translate(-6vmax, 3vmax) scale(.94); } }
39
+ .call-ov[data-st="ended"] .call-amb { opacity: .28; }
40
+ .call-ov > :not(.call-amb) { position: relative; z-index: 1; }
41
+
42
+ .call-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: calc(14px + var(--safe-t)) 22px 6px; max-width: 760px; width: 100%; margin: 0 auto; }
43
+ .call-me { display: flex; align-items: center; gap: 11px; min-width: 0; }
44
+ .call-me .call-bot { --s: 30px; color: #fff; transform: scale(calc(1 + var(--lvl) * .25)); transition: transform .1s linear; }
45
+ .call-me > span { display: grid; line-height: 1.2; }
46
+ .call-me b { font-size: 15px; font-weight: 600; }
47
+ .call-me small { font-size: 13px; color: rgba(255,255,255,.62); }
48
+ .call-time { font-size: 14px; color: rgba(255,255,255,.62); font-variant-numeric: tabular-nums; }
49
+
50
+ .call-lyrics { overflow-y: auto; scrollbar-width: none; -webkit-mask-image: linear-gradient(transparent 0, #000 16%, #000 78%, transparent 100%); mask-image: linear-gradient(transparent 0, #000 16%, #000 78%, transparent 100%); }
51
+ .call-lyrics::-webkit-scrollbar { display: none; }
52
+ .call-lines { display: grid; gap: 26px; max-width: 760px; margin: 0 auto; padding: 26vh 24px 52vh; }
53
+ .call-line { font-size: clamp(27px, 7.2vw, 42px); font-weight: 700; line-height: 1.17; letter-spacing: -.035em; text-wrap: pretty; overflow-wrap: anywhere; transition: opacity .5s var(--ease), filter .5s var(--ease), transform .5s var(--ease); }
54
+ .call-line:not(.now) { opacity: .42; filter: blur(1.2px); transform: scale(.97); transform-origin: left center; }
55
+ .call-line.nio span { color: rgba(255,255,255,.32); transition: color .22s ease-out, text-shadow .4s; }
56
+ .call-line.nio span.on { color: #fff; }
57
+ .call-line.nio.now span.on { text-shadow: 0 0 22px rgba(255,255,255,.18); }
58
+ .call-line.tu { justify-self: end; max-width: 86%; font-size: clamp(20px, 5.4vw, 30px); font-weight: 600; text-align: right; color: rgba(255,255,255,.72); }
59
+ .call-line.tu:not(.now) { transform-origin: right center; }
60
+ .call-line.ear { display: flex; gap: 7px; padding: 8px 2px; opacity: 1; filter: none; transform: none; }
61
+ .call-line.ear[hidden] { display: none; }
62
+ .call-line.ear i { width: 11px; height: 11px; border-radius: 50%; background: rgba(255,255,255,.8); transform: scale(calc(.55 + var(--lvl) * 1.1)); transition: transform .08s linear; }
63
+ .call-line.ear i:nth-child(2) { transform: scale(calc(.55 + var(--lvl) * 1.5)); }
64
+ .call-line.info { font-size: 15px; font-weight: 500; letter-spacing: 0; color: rgba(255,255,255,.6); filter: none; transform: none; opacity: 1; background: linear-gradient(90deg, rgba(255,255,255,.45) 30%, #fff 50%, rgba(255,255,255,.45) 70%) 0 0 / 300% 100%; -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; animation: call-shimmer 1.6s linear infinite; }
65
+ @keyframes call-shimmer { to { background-position: -150% 0; } }
66
+ .call-line.err { font-size: clamp(20px, 5vw, 26px); color: #ffb3a8; }
67
+ .call-ov.over .call-line { opacity: .9; filter: none; transform: none; }
68
+ .call-ov.over .call-line.tu { opacity: .7; }
69
+
70
+ .call-bar { display: flex; align-items: center; justify-content: center; gap: 40px; padding: 12px 20px calc(26px + var(--safe-b)); }
71
+ .call-ctl { display: grid; place-items: center; width: 62px; height: 62px; padding: 0; border: 0; border-radius: 50%; background: rgba(255,255,255,.14); color: #fff; cursor: pointer; -webkit-backdrop-filter: blur(20px); backdrop-filter: blur(20px); transition: background .18s, color .18s, transform .16s var(--ease); -webkit-tap-highlight-color: transparent; }
72
+ .call-ctl .ico { width: 26px; height: 26px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
73
+ .call-ctl:active { transform: scale(.92); }
74
+ .call-ctl[aria-pressed="true"] { background: #fff; color: #141112; }
75
+ .call-ov .call-btn.go { height: 62px; min-width: 0; padding: 0 26px; font-size: 16px; }
76
+ .call-ov .call-btn.go .ico { width: 22px; height: 22px; }
77
+ .call-ov :focus-visible { outline: 2px solid #fff; outline-offset: 3px; }
78
+ body:has(.call-ov) { overflow: hidden; }
79
+ @media (min-width: 900px) { .call-lines { padding-inline: 40px; } }
80
+ @media (prefers-reduced-motion: reduce) { .call-amb i, .call-line.info, .call-ring { animation: none; } .call-line { transition: none; } }