nio-cat 0.9.7 → 0.9.9

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"/>',
@@ -363,6 +365,7 @@ function renameMachine(host, id) {
363
365
 
364
366
  function handle(msg) {
365
367
  const r = state.route;
368
+ callCtl?.onEvent(msg);
366
369
  switch (msg.type) {
367
370
  case 'device':
368
371
  if (!isPhone() && msg.device !== deviceId()) { if ($('#qrBox')) closeSheet(); toast('Mòbil connectat correctament', 2000); }
@@ -510,18 +513,19 @@ function checkPairHash() {
510
513
  /* ---------------------------------------------------------------- navegació */
511
514
  const VIEWS = {
512
515
  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
516
  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' }
517
+ approvals: { label: 'Aprovacions', icon: ICON.approvals, group: 'Feina' },
518
+ trucada: { label: 'Trucada', icon: ICON.call, group: 'Feina' },
519
+ notes: { label: 'Notes', icon: ICON.notes, group: 'Organització' },
520
+ memoria: { label: 'Memòria', icon: ICON.memory, group: 'Organització' },
521
+ pissarra: { label: 'Pissarra', icon: ICON.board, group: 'Organització' },
522
+ ordinadors: { label: 'Ordinadors', icon: ICON.machine, group: 'Ordinadors i eines' },
523
+ mobil: { label: 'Xat local', icon: ICON.phone, group: 'Ordinadors i eines' },
524
+ activitat: { label: 'Activitat', icon: ICON.activity, group: 'Ordinadors i eines' },
525
+ ajustos: { label: 'Ajustos', icon: ICON.settings, group: 'Preferències' },
526
+ ajuda: { label: 'Ajuda', icon: ICON.help, group: 'Preferències' }
523
527
  };
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' };
528
+ 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
529
  const NEEDS_MACHINE = new Set(['session']);
526
530
  function parseRoute() {
527
531
  const h = location.hash.replace(/^#\/?/, '');
@@ -557,8 +561,8 @@ function renderChrome() {
557
561
  const m = machine(), sc = $('#sideConn');
558
562
  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
563
  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']]
564
+ const more = ['feines', 'trucada', 'memoria', 'approvals', 'mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].includes(r.name);
565
+ $('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['notes', ICON.notes, 'Notes', active === 'notes'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
562
566
  .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
567
  .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
568
  + `<button type="button" data-act="more" ${more ? 'aria-current="page"' : ''}>${svg(ICON.more)}<span>Més</span></button>`;
@@ -571,8 +575,9 @@ function renderChrome() {
571
575
  document.title = n ? `(${n}) nio` : `nio // ${s ? s.title || 'Sessió' : VIEWS[r.name]?.label || 'Panell'}`;
572
576
  if ('setAppBadge' in navigator) (n ? navigator.setAppBadge(n) : navigator.clearAppBadge()).catch?.(() => {});
573
577
  }
574
- let notesCtl = null;
575
- const getNotes = () => (notesCtl ||= createNotes({ api, nodeAudio, toast, esc, machine, voiceOn, voiceSheet, isDemo, openSheet, closeSheet, mobileQrSheet }));
578
+ let notesCtl = null, callCtl = null;
579
+ 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: () => {} }));
580
+ const getNotes = () => (notesCtl ||= createNotes({ api, nodeAudio, toast, esc, machine, voiceOn, voiceSheet, isDemo, isPhone, openSheet, closeSheet, mobileQrSheet }));
576
581
  function render() {
577
582
  const r = state.route, view = $('#view'), board = r.name === 'pissarra';
578
583
  if (r.name !== 'sessions') manage = null;
@@ -581,9 +586,11 @@ function render() {
581
586
  document.body.classList.toggle('chat-mode', r.name === 'session' || (r.name === 'mobil' && inLocalChat()));
582
587
  document.body.classList.toggle('no-tabs', r.name === 'session' || board || (r.name === 'mobil' && inLocalChat()));
583
588
  if (r.name !== 'notes' && lastName === 'notes') notesCtl?.unmount();
589
+ if (r.name !== 'trucada' && lastName === 'trucada') callCtl?.unmount();
584
590
  if (!board && lastName === 'pissarra') window.NioBoard?.unmount();
585
591
  if (board && lastName === 'pissarra' && $('#boardRoot')) return;
586
592
  if (r.name === 'notes' && lastName === 'notes' && $('#notesRoot') && machine()) return; // l'editor no es toca quan l'app es repinta
593
+ 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
594
  const changed = lastName !== r.name || r.name === 'session';
588
595
  lastName = r.name;
589
596
  if (r.name === 'session') renderSession();
@@ -591,12 +598,14 @@ function render() {
591
598
  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
599
  if (machine()) getNotes().mount($('#notesRoot'));
593
600
  }
601
+ else if (r.name === 'trucada') { view.innerHTML = `<div class="page" id="callRoot" data-m="${store.active}"></div>`; getCall().mount($('#callRoot')); }
594
602
  else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
595
603
  else {
596
604
  const html = r.name === 'mobil' ? viewLocal() : null;
597
605
  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
606
  if (r.name === 'mobil') bindLocal();
599
607
  if (r.name === 'mobil' && machine() && !inLocalChat()) loadOllama();
608
+ if (r.name === 'sessions' && machine() && state.connected) loadTermSessions();
600
609
  if (r.name === 'activitat' && machine()) { activity = null; loadActivity(); }
601
610
  if (r.name === 'memoria' && machine()) loadMemory();
602
611
  if (r.name === 'ordinadors' && machine() && state.connected) loadScreen();
@@ -611,7 +620,7 @@ function render() {
611
620
 
612
621
  /* ---------- Ordinadors (llista, afegir i detall de l'actiu) */
613
622
  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;
623
+ const isPhone = () => navigator.userAgentData?.mobile === true || /Android|iPhone|iPod|Mobile/i.test(navigator.userAgent) || (isTouch && matchMedia('(max-width: 1024px)').matches);
615
624
  const INSTALL_CMD = 'curl -fsSL https://www.nio.cat/install.sh | sh';
616
625
  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
626
  // Estat de la resta d'ordinadors (el connectat ja té el seu flux en directe): una consulta lleugera.
@@ -655,6 +664,7 @@ function helpPanel(open) {
655
664
 
656
665
  ${grp('Treballar amb els agents')}
657
666
  ${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>`)}
667
+ ${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
668
  ${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
669
  ${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
670
  ${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 +681,7 @@ function helpPanel(open) {
671
681
  ${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
682
  ${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
683
  ${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>`)}
684
+ ${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
685
 
675
686
  ${grp('Eines')}
676
687
  ${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 +846,7 @@ function addComputerSheet(tab, opts = {}) {
835
846
  addMachine({ url: r.url, token: r.token, name: keepName || r.host, kind: 'vps', via: target });
836
847
  if (keepName) { const ms = store.machines; ms[store.active].renamed = true; store.machines = ms; }
837
848
  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>`;
849
+ 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
850
  } 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
851
  };
841
852
  const vps = () => {
@@ -911,8 +922,9 @@ async function pantallaTab() {
911
922
  });
912
923
  }
913
924
  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(); }; });
925
+ const item = (k) => `<a class="more-link" href="${HASH[k]}" ${state.route.name === k ? 'aria-current="page"' : ''}><span>${VIEWS[k].label}</span>${k === 'approvals' && state.approvals.size ? `<span class="badge">${state.approvals.size}</span>` : ''}</a>`;
926
+ const groups = [...new Set(Object.values(VIEWS).map((v) => v.group))].map((group) => [group, Object.keys(VIEWS).filter((k) => VIEWS[k].group === group)]);
927
+ 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
928
  }
917
929
 
918
930
  /* ---------- Sessions */
@@ -944,6 +956,46 @@ async function manageDelete() {
944
956
  toast(fail ? `No he pogut esborrar ${fail} sessions` : `${ids.length} sessions esborrades`); render();
945
957
  }
946
958
  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();
959
+ // Sessions de Claude Code obertes al terminal de l'ordinador: es poden continuar des d'aquí.
960
+ let termSessions = null, termTimer = 0, termAt = 0, termFor = '';
961
+ async function loadTermSessions(force) {
962
+ if (!machine() || !state.connected || state.route.name !== 'sessions') return;
963
+ const key = machine().url;
964
+ if (termFor !== key) { termSessions = null; termFor = key; }
965
+ // render() es crida sovint: només es torna a demanar si fa una estona.
966
+ if (!force && Date.now() - termAt < 4000) return;
967
+ clearTimeout(termTimer); termAt = Date.now();
968
+ try { const r = (await api('/api/claude-sessions')).sessions || []; if (termFor === key) termSessions = r; } catch { termSessions = termSessions || []; }
969
+ const box = $('#termSessions'); if (box && state.route.name === 'sessions') box.innerHTML = termSessionsHtml();
970
+ // Es manté al dia mentre la pàgina és oberta: el terminal canvia sense avisar.
971
+ if (!document.hidden) termTimer = setTimeout(() => loadTermSessions(true), 8000);
972
+ }
973
+ document.addEventListener('visibilitychange', () => { if (!document.hidden && state.route.name === 'sessions') loadTermSessions(true); });
974
+ const TERM_STATUS = { busy: ['Treballant', 'running'], idle: ['Esperant', ''], waiting: ['Et necessita', 'waiting'], needs_input: ['Et necessita', 'waiting'] };
975
+ function termSessionsHtml() {
976
+ if (!termSessions?.length) return '';
977
+ const taken = new Set([...state.sessions.values()].flatMap((s) => [s.nativeId, s.fromTerminal]).filter(Boolean));
978
+ const fresh = termSessions.filter((s) => !taken.has(s.sessionId));
979
+ if (!fresh.length) return '';
980
+ const where = (t) => [t?.app, t?.tmux && 'tmux', t?.screen && 'screen', t?.ssh && 'SSH'].filter(Boolean).join(' · ');
981
+ return `<section class="term-sessions" aria-label="Al terminal"><h2 class="sec">Obertes al terminal</h2><div class="list">${fresh.map((s) => {
982
+ const [label, cls] = TERM_STATUS[s.status] || [s.status, ''];
983
+ 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 || '')}">
984
+ <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>
985
+ <div class="title">${esc(s.title || s.name || basename(s.cwd || '') || 'Claude Code')}</div>
986
+ ${s.ask ? `<div class="last">${esc(plain(s.ask))}</div>` : ''}
987
+ <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>
988
+ </button>`;
989
+ }).join('')}</div></section>`;
990
+ }
991
+ function sessionCard(s) {
992
+ 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' : ''}">
993
+ <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>
994
+ <div class="title">${esc(s.title || 'Sessió nova')}</div>
995
+ ${s.state === 'error' && s.error ? `<div class="last err">${esc(s.error)}</div>` : s.last ? `<div class="last">${esc(plain(s.last))}</div>` : ''}
996
+ <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>
997
+ </${manage ? 'div' : 'a'}>`;
998
+ }
947
999
  function viewSessions() {
948
1000
  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
1001
  const list = [...state.sessions.values()].sort((a, b) => b.updated - a.updated);
@@ -956,13 +1008,11 @@ function viewSessions() {
956
1008
  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
1009
  }
958
1010
  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>`}`;
1011
+ 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));
1012
+ const recentHtml = recent.length ? `<div class="list">${recent.map(sessionCard).join('')}</div>` : '';
1013
+ 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>` : '';
1014
+ const termHtml = `<div id="termSessions">${termSessionsHtml()}</div>`;
1015
+ 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
1016
  }
967
1017
 
968
1018
  /* ---------- Aprovacions */
@@ -1021,7 +1071,7 @@ async function loadActivity() {
1021
1071
  let mem = null;
1022
1072
  function viewMemory() {
1023
1073
  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>
1074
+ 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
1075
  <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
1076
  <div id="memBody">${memHtml()}</div>`;
1027
1077
  }
@@ -1040,14 +1090,18 @@ async function loadMemory() {
1040
1090
  async function memEdit(p) {
1041
1091
  let f = { path: p || 'projectes/', content: '' };
1042
1092
  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>'}
1093
+ openSheet(`<h2>${p ? esc(p) : 'Fitxer nou'}</h2><form class="stack" id="memForm">
1094
+ ${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
1095
  <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 () => {
1096
+ <p id="memError" class="form-error" role="alert" hidden></p>
1097
+ <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) => {
1098
+ $('#memForm', sh).onsubmit = async (event) => {
1099
+ event.preventDefault();
1048
1100
  const path = p || $('#memPath', sh).value.trim();
1101
+ const button = $('#memSave', sh), error = $('#memError', sh); button.disabled = true; button.textContent = 'Desant…'; error.hidden = true;
1049
1102
  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); }
1103
+ catch (e) { error.textContent = `${e.message} Pots tornar a prémer «Desa».`; error.hidden = false; }
1104
+ finally { button.disabled = false; button.textContent = 'Desa'; }
1051
1105
  };
1052
1106
  if (p) $('#memDel', sh).onclick = async () => {
1053
1107
  if (!confirm(`S’esborrarà «${p}» de la memòria. Els agents ja no ho recordaran.`)) return;
@@ -1398,7 +1452,7 @@ function eventNode(e) {
1398
1452
  const s = state.sessions.get(state.route.id);
1399
1453
  switch (e.kind) {
1400
1454
  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>`);
1455
+ 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>`);
1402
1456
  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>`);
1403
1457
  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
1458
  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)); }
@@ -1576,10 +1630,11 @@ function renderComposer() {
1576
1630
 
1577
1631
  /* ---------- Fulls */
1578
1632
  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}`;
1633
+ 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
1634
  if (!d.open) d.showModal(); raiseToasts();
1581
1635
  if (isTouch) { if (document.activeElement?.matches?.('input, textarea, select')) document.activeElement.blur(); } else setTimeout(() => focusIn(d), 30); if (T.on) $('#tour').hidden = true;
1582
1636
  onMount?.($('#sheetIn'));
1637
+ 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
1638
  }
1584
1639
  const closeSheet = () => { const d = $('#sheet'); if (d.open) d.close(); setTimeout(focusMain, 0); };
1585
1640
  // A l'ordinador el cursor és sempre on s'escriu; al mòbil no, perquè no s'obri el teclat.
@@ -1590,9 +1645,50 @@ function focusMain() {
1590
1645
  if (r === 'session') $('#msg')?.focus({ preventScroll: true });
1591
1646
  else if (r === 'mobil' && inLocalChat()) $('#lmsg')?.focus({ preventScroll: true });
1592
1647
  }
1593
- $('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet') closeSheet(); });
1648
+ $('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet' || e.target.closest('[data-sheet-close]')) closeSheet(); });
1594
1649
  $('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
1595
1650
 
1651
+ // Al mòbil, els fulls es tanquen lliscant cap avall, com els de l'iPhone: el full segueix el dit
1652
+ // 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.
1653
+ (() => {
1654
+ const d = $('#sheet');
1655
+ let g = null;
1656
+ 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; };
1657
+ const reset = () => { d.style.transition = ''; d.style.transform = ''; };
1658
+ d.addEventListener('touchstart', (e) => {
1659
+ g = null;
1660
+ if (!mq.matches || e.touches.length !== 1 || e.target.closest('input, textarea, select, [contenteditable], input[type=range], [data-noswipe]')) return;
1661
+ const t = e.touches[0], sc = scrollerOf(e.target);
1662
+ const head = !!e.target.closest('.grab, .wiz-head, h2');
1663
+ if (sc && sc.scrollTop > 0 && !head) return;
1664
+ g = { y0: t.clientY, x0: t.clientX, dy: 0, lock: null, sc: head ? null : sc, pts: [[e.timeStamp, t.clientY]] };
1665
+ }, { passive: true });
1666
+ d.addEventListener('touchmove', (e) => {
1667
+ if (!g) return;
1668
+ const t = e.touches[0], dy = t.clientY - g.y0, dx = t.clientX - g.x0;
1669
+ if (!g.lock) {
1670
+ if (Math.abs(dy) < 6 && Math.abs(dx) < 6) return;
1671
+ if (dy > 0 && dy > Math.abs(dx) && !(g.sc && g.sc.scrollTop > 0)) { g.lock = 'y'; d.style.transition = 'none'; }
1672
+ else { g = null; return; }
1673
+ }
1674
+ if (e.cancelable) e.preventDefault();
1675
+ g.dy = Math.max(0, dy); g.pts.push([e.timeStamp, t.clientY]); if (g.pts.length > 5) g.pts.shift();
1676
+ d.style.transform = `translateY(${g.dy}px)`;
1677
+ }, { passive: false });
1678
+ const end = (e) => {
1679
+ const x = g; g = null; if (!x || x.lock !== 'y') return;
1680
+ const [t0, y0] = x.pts[0], [t1, y1] = x.pts[x.pts.length - 1], v = (y1 - y0) / Math.max(1, t1 - t0);
1681
+ if (e.type === 'touchend' && (x.dy > Math.min(160, d.offsetHeight * 0.28) || (v > 0.5 && x.dy > 20))) {
1682
+ if (reduce) { reset(); closeSheet(); return; }
1683
+ d.style.transition = 'transform .2s cubic-bezier(.4,0,1,1)'; d.style.transform = `translateY(${d.offsetHeight + 20}px)`;
1684
+ setTimeout(() => { closeSheet(); requestAnimationFrame(reset); }, 200);
1685
+ } else { d.style.transition = 'transform .3s cubic-bezier(.22,1,.36,1)'; d.style.transform = ''; setTimeout(reset, 300); }
1686
+ };
1687
+ d.addEventListener('touchend', end, { passive: true });
1688
+ d.addEventListener('touchcancel', end, { passive: true });
1689
+ d.addEventListener('close', reset);
1690
+ })();
1691
+
1596
1692
  // Claude Code no deixa «Via lliure» quan nio corre com a root (típic en un VPS).
1597
1693
  const modeBlocked = (agentId, mode) => agentId === 'claude' && mode === 'tot' && state.info?.user === 'root';
1598
1694
  function modeHelp(agentId, mode) {
@@ -2468,6 +2564,8 @@ document.addEventListener('click', async (e) => {
2468
2564
  // Les claus (ElevenLabs o OpenAI) viuen a l'ordinador. L'àudio fa: mòbil → ordinador → proveïdor.
2469
2565
  let nodeVoice = null; // estat de la veu de l'ordinador actiu (sense claus)
2470
2566
  const voiceOn = () => !!nodeVoice?.stt;
2567
+ // Escoltar necessita una clau d'ElevenLabs o OpenAI: amb Whisper sol no hi ha veu per parlar.
2568
+ const ttsOn = () => !!nodeVoice?.tts;
2471
2569
  async function loadVoice() { try { nodeVoice = await api('/api/voice'); } catch { nodeVoice = null; } }
2472
2570
  async function nodeAudio(path, opts) {
2473
2571
  const m = machine(); if (!m) throw new Error('No hi ha cap ordinador connectat');
@@ -2534,13 +2632,15 @@ async function voiceSheet() {
2534
2632
  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
2633
  <div id="whisperBox" style="margin-top:14px">${whisperCard(v.whisper)}</div>
2536
2634
  <form class="stack" id="voiceForm" style="margin-top:14px">
2635
+ <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
2636
  <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
2637
  <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
2638
  <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
2639
  <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
2640
  <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
2641
  ${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>
2642
+ <button class="btn ghost" type="button" id="vOff">Treu les claus</button></div></details>
2643
+ <div class="row sheet-foot"><button class="btn ink" type="submit">Desa</button></div>
2544
2644
  </form>`, (sh) => {
2545
2645
  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
2646
  $('#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 +2719,17 @@ document.addEventListener('click', async (e) => {
2619
2719
  if (a.dataset.qdrop) { queue.drop(a.dataset.qdrop); return render(); }
2620
2720
  switch (a.dataset.act) {
2621
2721
  case 'new': return newSessionSheet();
2722
+ case 'resume': {
2723
+ const btn = a; btn.disabled = true;
2724
+ const sid = btn.dataset.sid, cwd = btn.dataset.cwd, sname = btn.dataset.sname;
2725
+ try {
2726
+ const title = sname || basename(cwd) || 'Terminal';
2727
+ const ns = await api('/api/sessions', { method: 'POST', body: { agent: 'claude', cwd: cwd || '', mode: 'preguntar', resumeId: sid, title } });
2728
+ state.sessions.set(ns.id, ns); termSessions = termSessions?.filter((x) => x.sessionId !== sid) || null;
2729
+ go(`#/s/${ns.id}`);
2730
+ } catch (err) { toast(err.message, 4200); btn.disabled = false; }
2731
+ return;
2732
+ }
2622
2733
  case 'manage-on': manage = new Set(); return render();
2623
2734
  case 'manage-off': manage = null; return render();
2624
2735
  case 'manage-del': return manageDelete();
@@ -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; } }