nio-cat 0.9.0 → 0.9.2

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
@@ -479,9 +479,10 @@ function renderChrome() {
479
479
  const m = machine(), sc = $('#sideConn');
480
480
  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)}`;
481
481
  sc.setAttribute('role', 'button'); sc.tabIndex = 0; sc.classList.add('switch'); sc.dataset.act = 'machines'; sc.title = 'Ordinadors';
482
- const more = ['mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].includes(r.name);
483
- $('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['approvals', ICON.approvals, 'Aprovacions', active === 'approvals'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
482
+ const more = ['feines', 'memoria', 'approvals', 'mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].includes(r.name);
483
+ $('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
484
484
  .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('')
485
+ .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"`)
485
486
  + `<button type="button" data-act="more" ${more ? 'aria-current="page"' : ''}>${svg(ICON.more)}<span>Més</span></button>`;
486
487
  const s = r.name === 'session' ? state.sessions.get(r.id) : null;
487
488
  $('#here').textContent = s ? s.title || 'Sessió nova' : VIEWS[r.name]?.label || '';
@@ -496,8 +497,8 @@ function render() {
496
497
  if (r.name !== 'sessions') manage = null;
497
498
  renderChrome();
498
499
  document.body.classList.toggle('board-mode', board);
499
- document.body.classList.toggle('chat-mode', r.name === 'session' || (r.name === 'mobil' && !!local.engine));
500
- document.body.classList.toggle('no-tabs', r.name === 'session' || board || (r.name === 'mobil' && !!local.engine));
500
+ document.body.classList.toggle('chat-mode', r.name === 'session' || (r.name === 'mobil' && inLocalChat()));
501
+ document.body.classList.toggle('no-tabs', r.name === 'session' || board || (r.name === 'mobil' && inLocalChat()));
501
502
  if (!board && lastName === 'pissarra') window.NioBoard?.unmount();
502
503
  if (board && lastName === 'pissarra' && $('#boardRoot')) return;
503
504
  const changed = lastName !== r.name || r.name === 'session';
@@ -506,9 +507,9 @@ function render() {
506
507
  else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
507
508
  else {
508
509
  const html = r.name === 'mobil' ? viewLocal() : null;
509
- view.innerHTML = html && local.engine ? 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>`;
510
+ 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>`;
510
511
  if (r.name === 'mobil') bindLocal();
511
- if (r.name === 'mobil' && machine() && !local.engine) loadOllama();
512
+ if (r.name === 'mobil' && machine() && !inLocalChat()) loadOllama();
512
513
  if (r.name === 'activitat' && machine()) { activity = null; loadActivity(); }
513
514
  if (r.name === 'memoria' && machine()) loadMemory();
514
515
  if (r.name === 'ordinadors' && machine() && state.connected) loadScreen();
@@ -552,12 +553,60 @@ function computerCard(m, i) {
552
553
  // Ajuda sempre a mà: com es fa cada cosa, i què mirar si no connecta.
553
554
  function helpPanel(open) {
554
555
  const q = (t, body) => `<details><summary>${t}</summary><div class="help-body">${body}</div></details>`;
556
+ const k = (t) => `<span class="mono">${t}</span>`;
557
+ const blk = (t) => `<pre class="cmdb"><code>${t}</code></pre>`;
558
+ const rows = (list) => `<div class="cmdlist">${list.map(([c, d]) => `<div><span class="mono">${c}</span><span>${d}</span></div>`).join('')}</div>`;
559
+ const grp = (t) => `<h3 class="help-grp">${t}</h3>`;
555
560
  return `<section class="panel help" id="help"><header><h2>Com es fa</h2></header><div class="body">
556
- ${q('Connectar aquest ordinador', `<p>Al terminal, des de la carpeta dels teus projectes, executa <span class="mono">npx nio-cat</span>. S’obre nio.cat ja connectat. Cal Node.js 18 o més nou.</p>`)}
557
- ${q('Fer-lo servir des del mòbil', `<p>A l’ordinador, a Ordinadors, prem <b>Continua al mòbil</b>. Surt un QR i un codi <span class="mono">nio-…</span>.</p><p>Al mòbil, obre <b>www.nio.cat/app</b> → Ordinadors → Afegeix ordinador, i escaneja el QR o enganxa el codi.</p><p>Per connectar-t’hi des de qualsevol xarxa cal <span class="mono">cloudflared</span> a l’ordinador (<span class="mono">brew install cloudflared</span>). Sense ell, el mòbil ha d’estar a la mateixa wifi.</p>`)}
558
- ${q('Afegir un servidor (VPS) amb Claude Code', `<p>Ordinadors → Afegeix ordinador → <b>Servidor (SSH)</b> → escriu <span class="mono">usuari@servidor</span>. L’ordinador hi entra, hi engega nio i el servidor surt a la llista.</p><p>Després, prem <b>Continua al mòbil</b> amb el servidor actiu per fer-lo servir des del mòbil.</p>`)}
559
- ${q('Què cal al servidor', `<ul><li>Entrar-hi per SSH sense contrasenya. Si no, executa <span class="mono">ssh-copy-id usuari@servidor</span> a l’ordinador.</li><li>Node.js 18 o més nou.</li><li>Claude Code instal·lat i amb sessió iniciada: entra al servidor i executa <span class="mono">claude</span> un cop.</li><li>Linux amb <span class="mono">curl</span> o <span class="mono">wget</span> (nio hi instal·la <span class="mono">cloudflared</span> sol).</li></ul>`)}
560
- ${q('No es connecta', `<ul><li><b>«No arribo a l’ordinador»</b>: comprova que nio continua engegat al terminal.</li><li><b>El servidor ha deixat de respondre</b>: si s’ha reiniciat, l’adreça canvia. Prem <b>Torna a connectar</b> a la seva targeta.</li><li><b>Safari no connecta</b>: obre nio amb <span class="mono">npx nio-cat --local</span>, o fes servir Chrome.</li><li><b>La clau no és vàlida</b>: torna a afegir l’ordinador amb el codi nou.</li></ul>`)}
561
+ ${grp('Per començar')}
562
+ ${q('Què és nio', `<p>nio et deixa donar feina als agents d’IA del teu ordinador (Claude Code, Codex, Grok, Antigravity i Ollama) des del mòbil. Els agents <b>treballen a l’ordinador o al servidor</b>, amb els teus comptes. El mòbil és el comandament: hi segueixes la feina en directe i aproves el que facin.</p>`)}
563
+ ${q('Connectar aquest ordinador', `<p>Al terminal, des de la carpeta dels teus projectes, executa:</p>${blk('npx nio-cat')}<p>S’obre nio.cat ja connectat. Cal Node.js 18 o més nou. Pots indicar més carpetes: ${k('npx nio-cat ~/projectes')}.</p>`)}
564
+ ${q('Fer-lo servir des del mòbil', `<p>A l’ordinador, a Ordinadors, prem <b>Continua al mòbil</b>. Surt un QR i un codi ${k('nio-…')}.</p><p>Al mòbil, obre <b>www.nio.cat/app</b> → Ordinadors → Afegeix ordinador, i escaneja el QR o enganxa el codi. Guarda el codi com una contrasenya: qui el té, pot manar a l’ordinador.</p><p>Per connectar-t’hi des de qualsevol xarxa cal ${k('cloudflared')} a l’ordinador (${k('brew install cloudflared')}). Sense ell, el mòbil ha d’estar a la mateixa wifi. Si reinicies nio, l’adreça del túnel canvia: torna a escanejar el codi.</p>`)}
565
+ ${q('Afegir un servidor (VPS) amb Claude Code', `<p>Ordinadors → Afegeix ordinador → <b>Servidor (SSH)</b> → escriu ${k('usuari@servidor')}. L’ordinador hi entra, hi engega nio i el servidor surt a la llista. Després, prem <b>Continua al mòbil</b> amb el servidor actiu.</p><p>Des del terminal també es pot: ${k('npx nio-cat ssh usuari@servidor')}.</p>`)}
566
+ ${q('Què cal al servidor', `<ul><li>Entrar-hi per SSH sense contrasenya. Si no, executa ${k('ssh-copy-id usuari@servidor')} a l’ordinador.</li><li>Node.js 18 o més nou.</li><li>Claude Code instal·lat i amb sessió iniciada: entra al servidor i executa ${k('claude')} un cop.</li><li>Linux amb ${k('curl')} o ${k('wget')} (nio hi instal·la ${k('cloudflared')} sol).</li></ul>`)}
567
+
568
+ ${grp('Treballar amb els agents')}
569
+ ${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>`)}
570
+ ${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>`)}
571
+ ${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>`)}
572
+ ${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([
573
+ ['/mode', 'Canvia els permisos: ' + k('l') + ' llegir, ' + k('p') + ' preguntar, ' + k('t') + ' tot'],
574
+ ['/model', 'Canvia de model'],
575
+ ['/stop', 'Atura l’agent ara mateix'],
576
+ ['/nova', 'Nova sessió amb el mateix agent i carpeta'],
577
+ ['/carpeta', 'Canvia la carpeta de la sessió'],
578
+ ['/git', 'Git i GitHub: veure canvis, fer commit i crear un PR'],
579
+ ['/nom', 'Posa nom a la sessió (' + k('/nom Feina web') + ')'],
580
+ ['/opcions', 'Opcions de la sessió'],
581
+ ['/ajuda', 'Llista de comandes']
582
+ ])}<p>Exemple: ${k('/mode p')} passa la sessió a «Demana permís».</p>`)}
583
+ ${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>`)}
584
+ ${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>`)}
585
+ ${q('Veu', `<p>A <b>Ajustos → Veu</b> pots posar la teva clau d’ElevenLabs per <b>dictar</b> els missatges amb el micròfon i <b>escoltar</b> les respostes. La clau es queda només en aquest navegador.</p>`)}
586
+
587
+ ${grp('Eines')}
588
+ ${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>`)}
589
+ ${q('Pissarra', `<p>Un llenç infinit per fer diagrames a mà alçada: fluxos, mapes mentals i organigrames. Hi arribes des de <b>Més → Pissarra</b> (al mòbil) o al menú (a l’ordinador).</p><ul><li><b>Eines</b>: selecciona, mou el llenç, rectangle, el·lipse, rombe, fletxa, línia, llapis, text i goma. El cadenat manté l’eina activa.</li><li><b>Zoom amb dos dits</b>: al mòbil, pessiga sobre el llenç; amb els dos dits alhora també el mous. A l’ordinador, pessiga al trackpad o fes ${k('Ctrl')} + roda del ratolí.</li><li><b>Moure’s</b>: amb un dit, a l’espai buit (eina Selecciona), o amb ${k('Espai')} + arrossegar, o la roda del ratolí.</li><li><b>Text</b>: doble toc o doble clic per editar; doble toc a l’espai buit crea un text nou.</li><li><b>Zoom ràpid</b>: la xifra del percentatge torna al 100%; el botó de les cantonades ho enquadra tot.</li><li><b>Desfés / refés</b>: els botons de dalt a l’esquerra, o ${k('Ctrl Z')} i ${k('Ctrl ⇧ Z')}.</li><li><b>Estil</b>: color, gruix i traç (al mòbil, el botó de la paleta). Menú ⋯: exporta a PNG, SVG o JSON, i quadrícula.</li></ul>`)}
590
+ ${q('Demanar-li el dibuix a en Nio', `<p>Prem <b>Nio</b> (dalt a la dreta) per obrir el xat de la pissarra. Descriu què vols i ho dibuixa. Exemples:</p>${rows([
591
+ ['Flux: A -> B -> C', 'Una fletxa per línia; afegeix ' + k(': etiqueta') + ' si vols'],
592
+ ['Mapa mental de X: a, b, c', 'Tema central amb branques'],
593
+ ['Organigrama: Direcció > Finances, Operacions', 'Qui depèn de qui'],
594
+ ['Afegeix un rombe “Quadra?”', 'Afegeix una figura'],
595
+ ['Connecta Banc amb ERP', 'Uneix dues figures amb una fletxa'],
596
+ ['Pinta Banc de verd', 'Canvia el color'],
597
+ ['Reanomena Banc a Caixa', 'Canvia el text'],
598
+ ['Esborra ERP', 'Treu una figura'],
599
+ ['Ordena-ho · Centra · Desfés', 'Ordena, enquadra o desfà'],
600
+ ['Plantilla agent', 'També ' + k('Plantilla d’aprovació') + ' i ' + k('Plantilla conciliació')],
601
+ ['Exporta PNG', 'També ' + k('SVG') + ' o ' + k('JSON')]
602
+ ])}<p>Al desplegable <b>Amb</b> tries què t’entén: les <b>ordres ràpides</b> (sense gastar res) o un model.</p>`)}
603
+ ${q('Xat local', `<p>A <b>Més → Xat local</b> tens un model petit que funciona <b>dins del navegador del mòbil</b>, sense ordinador ni connexió un cop descarregat. Necessita un navegador amb WebGPU. Si la GPU del mòbil es reinicia, el xat es recupera sol.</p>`)}
604
+ ${q('Models d’Ollama', `<p>A <b>Models</b> veus els models d’Ollama de l’ordinador, en pots descarregar de nous amb el progrés en directe, esborrar-los i obrir-hi sessions. Funcionen al teu ordinador i no gasten res.</p>`)}
605
+ ${q('Buscador i dreceres', `<p>Prem ${k('Ctrl K')} (o ${k('⌘ K')}) per obrir el buscador d’accions: nova sessió, afegir ordinador, configurar la veu, fer el tutorial… Al mòbil, llisca cap als costats per canviar de pàgina.</p>`)}
606
+
607
+ ${grp('Si alguna cosa no va')}
608
+ ${q('No es connecta', `<ul><li><b>«No arribo a l’ordinador»</b>: comprova que nio continua engegat al terminal.</li><li><b>El servidor ha deixat de respondre</b>: si s’ha reiniciat, l’adreça canvia. Prem <b>Torna a connectar</b> a la seva targeta.</li><li><b>Safari no connecta</b>: obre nio amb ${k('npx nio-cat --local')}, o fes servir Chrome.</li><li><b>La clau no és vàlida</b>: torna a afegir l’ordinador amb el codi nou. Per invalidar tots els codis antics: ${k('npx nio-cat reset-token')}.</li><li><b>Quins agents té l’ordinador?</b> ${k('npx nio-cat doctor')} els llista.</li></ul>`)}
609
+ ${q('Seguretat', `<ul><li>L’enllaç de connexió porta una clau aleatòria que el navegador no envia a cap servidor web.</li><li>Cap dada no passa per servidors de nio: el mòbil parla directament amb el teu ordinador.</li><li>Totes les crides demanen la clau, que només tens tu.</li></ul>`)}
561
610
  ${q('D’on es gasta', `<p>nio no fa cap crida pel seu compte. Cada agent treballa a l’ordinador o al servidor amb el teu compte (Claude, Codex, Grok…). Ollama funciona al teu ordinador i no gasta res.</p>`)}
562
611
  </div></section>`;
563
612
  }
@@ -739,9 +788,28 @@ function renameSheet(i) {
739
788
  };
740
789
  });
741
790
  }
791
+ async function pantallaTab() {
792
+ if (!machine() || !state.connected) return toast('Primer connecta un ordinador.', 4000);
793
+ let caps = state.info?.control;
794
+ try { caps = await api('/api/control'); if (state.info) state.info.control = caps; } catch (e) { return toast(e.message, 4500); }
795
+ const d = caps?.escriptori;
796
+ if (d?.enabled) return remoteOpen();
797
+ const body = !d?.installed
798
+ ? `<p class="small">Per veure i controlar l’escriptori cal instal·lar <span class="mono">agent-desktop</span> a ${esc(machine().name)}:</p><p class="mono small">npm i -g agent-desktop</p>`
799
+ : d.ready === false ? `<p class="small">${esc(d.why || 'L’escriptori encara no està a punt.')}</p>`
800
+ : `<p class="small">Activa «Apps de l’escriptori» a ${esc(machine().name)} per veure la pantalla i controlar-la des del mòbil. Cada acció dels agents continuarà demanant la teva aprovació.</p>`;
801
+ openSheet(`<h2>Pantalla</h2><div class="stack">${body}<div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Ara no</button>${d?.installed && d.ready !== false ? '<button class="btn ink" id="pantOn" type="button">Activa i obre</button>' : ''}</div></div>`, (sh) => {
802
+ const go = sh.querySelector('#pantOn'); if (!go) return;
803
+ go.onclick = async () => {
804
+ go.disabled = true;
805
+ try { const r = await api('/api/control', { method: 'PUT', body: { escriptori: true } }); if (state.info) state.info.control = r; closeSheet(); remoteOpen(); }
806
+ catch (err) { toast(err.message, 6000); go.disabled = false; }
807
+ };
808
+ });
809
+ }
742
810
  function moreSheet() {
743
811
  const item = (k) => `<a class="card row" href="${HASH[k]}" style="gap:12px">${svg(VIEWS[k].icon)}<b>${VIEWS[k].label}</b></a>`;
744
- openSheet(`<h2>Més</h2><div class="list">${['mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].map(item).join('')}</div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
812
+ openSheet(`<h2>Més</h2><div class="list">${['feines', 'memoria', 'mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].map(item).join('')}</div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
745
813
  }
746
814
 
747
815
  /* ---------- Sessions */
@@ -772,6 +840,7 @@ async function manageDelete() {
772
840
  manage = fail ? new Set(ids.filter((id) => state.sessions.has(id))) : null;
773
841
  toast(fail ? `No he pogut esborrar ${fail} sessions` : `${ids.length} sessions esborrades`); render();
774
842
  }
843
+ 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();
775
844
  function viewSessions() {
776
845
  const list = [...state.sessions.values()].sort((a, b) => b.updated - a.updated);
777
846
  const many = store.machines.length > 1, mach = machine();
@@ -787,7 +856,7 @@ function viewSessions() {
787
856
  <${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' : ''}">
788
857
  <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>
789
858
  <div class="title">${esc(s.title || 'Sessió nova')}</div>
790
- ${s.state === 'error' && s.error ? `<div class="last err">${esc(s.error)}</div>` : s.last ? `<div class="last">${esc(s.last)}</div>` : ''}
859
+ ${s.state === 'error' && s.error ? `<div class="last err">${esc(s.error)}</div>` : s.last ? `<div class="last">${esc(plain(s.last))}</div>` : ''}
791
860
  <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>${s.cost ? `<span>·</span><span>${money(s.cost)}</span>` : ''}</div>
792
861
  </${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>`}`;
793
862
  }
@@ -935,28 +1004,45 @@ async function newJobSheet() {
935
1004
  try { projects = await api('/api/projects'); } catch (e) { return toast(e.message); }
936
1005
  const agents = (state.info?.agents || []).filter((a) => a.available && ['claude', 'codex'].includes(a.id));
937
1006
  const prev = (jobsData?.jobs || []).filter((j) => ['pendent', 'en curs', 'feta'].includes(j.state)).slice(0, 15);
938
- const sel = { agent: 'auto', mode: 'preguntar', when: 'ara' };
1007
+ const sel = { agent: 'auto', mode: 'preguntar', when: 'ara', mins: 0 };
939
1008
  openSheet(`<h2>Nova feina</h2>
940
1009
  <label class="f"><span>Què ha de fer?</span><textarea class="field" id="jobPrompt" rows="3" placeholder="Per exemple: revisa les issues noves i fes-me’n un resum"></textarea></label>
941
1010
  <div class="grid-2"><label class="f"><span>Agent</span><select id="jobAgent"><option value="auto">Automàtic (regles)</option>${agents.map((a) => `<option value="${a.id}">${esc(a.name)}</option>`).join('')}</select></label>
942
1011
  <label class="f"><span>Carpeta</span><select id="jobCwd">${projects.map((p) => `<option value="${esc(p.path)}">${esc(p.name)}</option>`).join('')}</select></label></div>
943
1012
  <div class="stack"><div class="section-title" style="margin:0">Permisos</div><div class="seg" id="jobMode" role="group">${Object.entries(MODES).map(([k, m]) => `<button type="button" data-jmode="${k}" aria-pressed="${k === sel.mode}">${m.label}</button>`).join('')}</div></div>
944
- <div class="grid-2"><label class="f"><span>Quan</span><select id="jobWhen"><option value="ara">Ara</option><option value="mes">Més tard…</option>${prev.length ? '<option value="after">Quan acabi una altra feina…</option>' : ''}<option value="cron">Periòdicament…</option></select></label>
945
- <div id="jobWhenX"></div></div>
1013
+ <div class="stack"><div class="section-title" style="margin:0">Quan es comença</div>
1014
+ <div class="seg" id="jobWhen" role="group" aria-label="Quan es comença"><button type="button" data-w="ara" aria-pressed="true">Ara</button><button type="button" data-w="timer" aria-pressed="false">D’aquí a…</button><button type="button" data-w="mes" aria-pressed="false">A una hora</button>${prev.length ? '<button type="button" data-w="after" aria-pressed="false">Després d’una altra</button>' : ''}<button type="button" data-w="cron" aria-pressed="false">Repetida</button></div>
1015
+ <div id="jobWhenX" class="stack"></div></div>
946
1016
  ${agents.some((a) => a.id === 'codex') ? '<label class="row check"><input type="checkbox" id="jobReview"> <span>Quan acabi, que Codex revisi els canvis</span></label>' : ''}
947
1017
  <div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Cancel·la</button><button class="btn ink" id="jobGo" type="button">Posa-la a la cua</button></div>`, (sh) => {
948
1018
  const x = $('#jobWhenX', sh);
949
- $('#jobWhen', sh).onchange = (e) => {
950
- sel.when = e.target.value;
951
- x.innerHTML = sel.when === 'mes' ? '<label class="f"><span>Dia i hora</span><input type="datetime-local" id="jobAt"></label>'
952
- : sel.when === 'after' ? `<label class="f"><span>Després de</span><select id="jobAfter">${prev.map((j) => `<option value="${esc(j.id)}">${esc(j.title)} (${esc(j.state)})</option>`).join('')}</select></label>`
953
- : sel.when === 'cron' ? `<label class="f"><span>Cada quan</span><select id="jobCron">${CRON_PRESETS.slice(1).map(([c, l]) => `<option value="${c}">${l}</option>`).join('')}</select></label><input type="text" id="jobCronX" placeholder="min hora dia mes dia-setmana" hidden autocapitalize="off" spellcheck="false" class="mono">` : '';
1019
+ const TIMES = [[5, '5 min'], [15, '15 min'], [30, '30 min'], [60, '1 h'], [180, '3 h'], [480, '8 h']];
1020
+ const hm = (t) => new Date(t).toLocaleString('ca', { weekday: 'short', hour: '2-digit', minute: '2-digit' });
1021
+ const upd = () => {
1022
+ const go = $('#jobGo', sh);
1023
+ if (sel.when === 'timer') { const out = $('#jobTimerOut', sh); out.innerHTML = sel.mins ? `Començarà a les <b>${hm(Date.now() + sel.mins * 60000)}</b>` : 'Tria quant ha d’esperar'; go.textContent = sel.mins ? `Envia-la d’aquí a ${fmtMins(sel.mins)}` : 'Posa-la a la cua'; }
1024
+ else go.textContent = { ara: 'Posa-la a la cua', mes: 'Programa-la', after: 'Encadena-la', cron: 'Programa-la' }[sel.when];
1025
+ };
1026
+ const fmtMins = (m) => (m % 60 === 0 && m >= 60 ? `${m / 60} h` : m > 60 ? `${Math.floor(m / 60)} h ${m % 60} min` : `${m} min`);
1027
+ const setWhen = (w) => {
1028
+ sel.when = w; $$('#jobWhen [data-w]', sh).forEach((b) => b.setAttribute('aria-pressed', b.dataset.w === w));
1029
+ x.innerHTML = w === 'timer' ? `<div class="row" style="flex-wrap:wrap;gap:8px" id="jobTimes">${TIMES.map(([m, l]) => `<button type="button" class="chip" data-m="${m}" aria-pressed="${m === sel.mins}">${l}</button>`).join('')}</div>
1030
+ <div class="row" style="flex-wrap:nowrap"><input class="field" type="number" id="jobMinsN" min="1" max="9999" inputmode="numeric" placeholder="Altre" style="max-width:110px" aria-label="Quantitat"><select id="jobMinsU" aria-label="Unitat"><option value="1">minuts</option><option value="60">hores</option><option value="1440">dies</option></select></div>
1031
+ <p class="muted small" id="jobTimerOut"></p>`
1032
+ : w === 'mes' ? '<label class="f"><span>Dia i hora</span><input type="datetime-local" id="jobAt"></label>'
1033
+ : w === 'after' ? `<label class="f"><span>Quan acabi</span><select id="jobAfter">${prev.map((j) => `<option value="${esc(j.id)}">${esc(j.title)} (${esc(j.state)})</option>`).join('')}</select></label>`
1034
+ : w === 'cron' ? `<label class="f"><span>Cada quan</span><select id="jobCron">${CRON_PRESETS.slice(1).map(([c, l]) => `<option value="${c}">${l}</option>`).join('')}</select></label><input type="text" id="jobCronX" placeholder="min hora dia mes dia-setmana" hidden autocapitalize="off" spellcheck="false" class="mono">` : '';
954
1035
  const c = $('#jobCron', sh); if (c) c.onchange = () => ($('#jobCronX', sh).hidden = c.value !== 'custom');
1036
+ upd();
955
1037
  };
1038
+ $('#jobWhen', sh).addEventListener('click', (e) => { const b = e.target.closest('[data-w]'); if (b) setWhen(b.dataset.w); });
1039
+ x.addEventListener('click', (e) => { const b = e.target.closest('[data-m]'); if (!b) return; sel.mins = Number(b.dataset.m); $$('[data-m]', x).forEach((c) => c.setAttribute('aria-pressed', c === b)); $('#jobMinsN', sh).value = ''; upd(); });
1040
+ x.addEventListener('input', (e) => { if (!e.target.matches('#jobMinsN, #jobMinsU')) return; const n = Number($('#jobMinsN', sh).value); sel.mins = n > 0 ? Math.round(n * Number($('#jobMinsU', sh).value)) : 0; $$('[data-m]', x).forEach((c) => c.setAttribute('aria-pressed', false)); upd(); });
956
1041
  sh.addEventListener('click', (e) => { const m = e.target.closest('[data-jmode]'); if (m) { sel.mode = m.dataset.jmode; $$('[data-jmode]', sh).forEach((b) => b.setAttribute('aria-pressed', b.dataset.jmode === sel.mode)); } if (e.target.closest('[data-close]')) closeSheet(); });
957
1042
  $('#jobGo', sh).onclick = async () => {
958
1043
  const prompt = $('#jobPrompt', sh).value.trim(); if (!prompt) return toast('Explica què ha de fer la feina');
959
1044
  const body = { prompt, agent: $('#jobAgent', sh).value, cwd: $('#jobCwd', sh).value, mode: sel.mode };
1045
+ if (sel.when === 'timer') { if (!sel.mins) return toast('Tria quant ha d’esperar'); body.at = Date.now() + sel.mins * 60000; }
960
1046
  if (sel.when === 'mes') { const v = $('#jobAt', sh)?.value; if (!v) return toast('Tria el dia i l’hora'); body.at = new Date(v).getTime(); }
961
1047
  if (sel.when === 'after') body.after = $('#jobAfter', sh).value;
962
1048
  if (sel.when === 'cron') body.cron = $('#jobCron', sh).value === 'custom' ? $('#jobCronX', sh).value.trim() : $('#jobCron', sh).value;
@@ -964,7 +1050,7 @@ async function newJobSheet() {
964
1050
  try {
965
1051
  const j = await api('/api/jobs', { method: 'POST', body });
966
1052
  if ($('#jobReview', sh)?.checked) await api('/api/jobs', { method: 'POST', body: { agent: 'codex', cwd: j.cwd, mode: 'llegir', after: j.id, title: `Revisió: ${j.title}`, prompt: 'Revisa els canvis que s’acaben de fer en aquesta carpeta (git diff i fitxers nous). Explica què està bé, què es pot millorar i si hi ha cap error. Context de la feina anterior:\n\n{{anterior}}' } });
967
- closeSheet(); toast(body.cron ? 'Feina programada' : 'Feina a la cua'); loadJobs();
1053
+ closeSheet(); toast(body.cron || body.at ? 'Feina programada' : 'Feina a la cua'); loadJobs();
968
1054
  } catch (e) { toast(e.message, 4200); $('#jobGo', sh).disabled = false; }
969
1055
  };
970
1056
  if (!isTouch) setTimeout(() => $('#jobPrompt', sh)?.focus(), 80);
@@ -1234,11 +1320,17 @@ function quickFolders(extra = []) {
1234
1320
  }
1235
1321
  const folderChip = (f) => `<button type="button" class="chip ${f.tag === 'fav' ? 'fav' : ''}" data-fs="${esc(f.path)}" title="${esc(f.path)}">${f.tag === 'fav' ? '★ ' : f.tag === 'last' ? '↺ ' : ''}${esc(f.name)}</button>`;
1236
1322
  const favBtn = (p) => `<button class="chip" type="button" data-fav aria-pressed="${getFavs().includes(p)}">${getFavs().includes(p) ? '★ Preferida' : '☆ Desa com a preferida'}</button>`;
1323
+ function crumbBar(d) {
1324
+ const sep = d.path.includes('\\') && !d.path.startsWith('/') ? '\\' : '/', parts = d.path.split(/[\\/]/).filter(Boolean);
1325
+ const pathAt = (i) => (sep === '/' ? '/' : '') + parts.slice(0, i + 1).join(sep) + (sep === '\\' && i === 0 ? '\\' : '');
1326
+ const from = Math.max(0, parts.length - 3);
1327
+ const segs = parts.slice(from).map((n, k) => { const i = from + k, cur = i === parts.length - 1; return `<button type="button" class="crumb${cur ? ' cur' : ''}" data-fs="${esc(pathAt(i))}"${cur ? ' aria-current="page"' : ''}>${esc(n)}</button>`; });
1328
+ return `<div class="fs-bar"><button class="icon-btn" type="button" data-fs="${esc(d.parent || '')}" ${d.parent ? '' : 'disabled'} aria-label="Carpeta de dalt">${svg(ICON.back)}</button><nav class="crumbs mono small" aria-label="Camí" title="${esc(d.path)}"><button type="button" class="crumb" data-fs="${esc(d.home)}" aria-label="Carpeta d’inici">~</button>${from > 0 ? '<span class="sl">/</span><span class="sl">…</span>' : ''}${segs.map((x) => `<span class="sl">/</span>${x}`).join('')}</nav></div>`;
1329
+ }
1237
1330
  function folderSheet(s) {
1238
1331
  let d = null;
1239
1332
  const draw = () => {
1240
- const crumbs = d ? d.path.split(/[\\/]/).filter(Boolean) : [];
1241
- $('#fsBody').innerHTML = !d ? '<p class="muted small">Carregant…</p>' : `<div class="row">${favBtn(d.path)}${quickFolders().slice(0, 8).map(folderChip).join('')}</div><div class="fs-bar"><button class="icon-btn" type="button" data-fs="${esc(d.parent || '')}" ${d.parent ? '' : 'disabled'} aria-label="Carpeta de dalt">${svg(ICON.back)}</button><button class="chip" type="button" data-fs="${esc(d.home)}">Inici</button><span class="fs-path mono small" title="${esc(d.path)}">${esc(crumbs.slice(-2).join(' / ') || '/')}</span></div>
1333
+ $('#fsBody').innerHTML = !d ? '<p class="muted small">Carregant…</p>' : `<div class="row">${favBtn(d.path)}${quickFolders().filter((f) => f.tag === 'fav').map(folderChip).join('')}</div>${crumbBar(d)}
1242
1334
  <div class="proj-list fs-list">${d.dirs.map((x) => `<button type="button" class="proj" data-fs="${esc(x.path)}"><span class="ic">›</span><span class="grow"><div ${x.hidden ? 'class="muted"' : ''}>${esc(x.name)}</div></span></button>`).join('') || '<p class="muted small">No hi ha subcarpetes. Pots triar aquesta.</p>'}</div>`;
1243
1335
  const ok = $('#fsOk'); if (ok) { ok.disabled = !d || d.path === s.cwd; ok.textContent = d && d.path === s.cwd ? 'Ja hi és' : 'Treballa aquí'; }
1244
1336
  };
@@ -1335,7 +1427,7 @@ function focusMain() {
1335
1427
  if (isTouch || $('#sheet')?.open || $('#palette')?.open) return;
1336
1428
  const r = state.route.name;
1337
1429
  if (r === 'session') $('#msg')?.focus({ preventScroll: true });
1338
- else if (r === 'mobil' && local.engine) $('#lmsg')?.focus({ preventScroll: true });
1430
+ else if (r === 'mobil' && inLocalChat()) $('#lmsg')?.focus({ preventScroll: true });
1339
1431
  }
1340
1432
  $('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet') closeSheet(); });
1341
1433
  $('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
@@ -1415,14 +1507,13 @@ async function newSessionSheet(preset = {}) {
1415
1507
  const VIEWS_ = [
1416
1508
  () => `<p class="wiz-q">Quin agent vols fer servir?</p><div class="picker">${[...agents].sort((x, y) => (y.available ? 1 : 0) - (x.available ? 1 : 0)).map((a) => `<button type="button" class="pick ${a.available ? '' : 'off'}" data-agent-id="${esc(a.id)}" aria-pressed="${a.id === sel.agent}"><div class="row between"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="sub">${esc(a.available ? a.version || 'Disponible' : 'No instal·lat')}</div></button>`).join('')}</div>`,
1417
1509
  () => {
1418
- const crumbs = fsd ? fsd.path.split(/[\\/]/).filter(Boolean) : [];
1419
- const qf = quickFolders(projects), favN = getFavs().length, quick = qf.filter((f) => f.tag).concat(qf.filter((f) => !f.tag).slice(0, 6)).slice(0, favN + 8).map(folderChip).join('');
1510
+ const quick = quickFolders().filter((f) => f.tag === 'fav').map(folderChip).join('');
1420
1511
  return `<p class="wiz-q">A quina carpeta ha de treballar?</p>
1421
1512
  <form class="pair-form" id="fsForm"><input type="text" id="fsIn" value="${esc(fsd?.path || sel.cwd)}" placeholder="/ruta/exacta/de/la/carpeta" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Ruta de la carpeta"><button class="btn" type="submit">Obre</button></form>
1422
1513
  <div class="fs-pick"><span class="muted small">Carpeta triada</span><b class="mono small">${esc(fsd?.path || sel.cwd || '—')}</b>${fsd?.git ? '<span class="chip">git</span>' : ''}${favBtn(fsd?.path || sel.cwd)}</div>
1423
1514
  <div class="row"><button class="chip" type="button" data-gh>${gh ? 'Torna a les carpetes' : 'Clona de GitHub'}</button>${quick}</div>
1424
1515
  ${gh ? (gh.msg ? `<p class="muted small">${gh.msg}</p>` : '') + (gh.repos ? `<input type="search" id="ghQ" value="${esc(gh.q)}" placeholder="Cerca entre els teus repositoris${gh.user ? ' (' + esc(gh.user) + ')' : ''}" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Cerca un repositori"><div class="proj-list fs-list" id="ghList">${ghList()}</div>` : '')
1425
- : !fsd ? '<p class="muted small">Carregant…</p>' : `<div class="fs-bar"><button class="icon-btn" type="button" data-fs="${esc(fsd.parent || '')}" ${fsd.parent ? '' : 'disabled'} aria-label="Carpeta de dalt">${svg(ICON.back)}</button><button class="chip" type="button" data-fs="${esc(fsd.home)}">Inici</button><span class="fs-path mono small" title="${esc(fsd.path)}">${esc(crumbs.slice(-2).join(' / ') || '/')}</span></div>
1516
+ : !fsd ? '<p class="muted small">Carregant…</p>' : `${crumbBar(fsd)}
1426
1517
  <div class="proj-list fs-list">${fsd.dirs.map((d) => `<button type="button" class="proj" data-fs="${esc(d.path)}"><span class="ic">›</span><span class="grow"><div ${d.hidden ? 'class="muted"' : ''}>${esc(d.name)}</div></span></button>`).join('') || '<p class="muted small">No hi ha subcarpetes. Pots triar aquesta.</p>'}</div>`}`;
1427
1518
  },
1428
1519
  () => {
@@ -1588,34 +1679,27 @@ async function loadOllama(force) {
1588
1679
  paintModels();
1589
1680
  }
1590
1681
  function ollamaSection() {
1591
- if (!machine()) return `<div class="sec-head"><div><h2 class="sec">Ollama a l’ordinador</h2><p class="muted small">Els models d’Ollama són més grans i capaços, però només funcionen en un ordinador. Connecta’n un per veure’ls i descarregar-ne.</p></div><a class="btn" href="#/ordinadors">Ordinadors</a></div>`;
1592
- return `<div class="sec-head"><div><h2 class="sec">Ollama a l’ordinador</h2><p class="muted small">Models que funcionen a ${esc(machine().name)}, sense internet i sense comptes. Els fas servir des de Sessions.</p></div><button class="btn" data-act="ollama-refresh" type="button">${svg(ICON.refresh)}Actualitza</button></div><div id="modelsBody">${state.ollama ? '' : `<div class="panel empty">${bot('work')}<h2>Consultant Ollama…</h2></div>`}</div>`;
1682
+ if (!machine()) return `<h2 class="sec" style="margin-top:36px">Ollama a l’ordinador</h2><div class="stack-lg" style="max-width:640px"><div class="card"><b>Només en un ordinador</b><div class="muted small" style="margin-top:4px">Els models d’Ollama són més grans i capaços. Connecta’n un per veure’ls i descarregar-ne.</div><a class="btn block" style="margin-top:10px" href="#/ordinadors">Ordinadors</a></div></div>`;
1683
+ return `<div class="row between" style="max-width:640px;margin-top:36px"><h2 class="sec" style="margin:0">Ollama a ${esc(machine().name)}</h2><button class="icon-btn" data-act="ollama-refresh" type="button" aria-label="Actualitza">${svg(ICON.refresh)}</button></div><div id="modelsBody" style="max-width:640px;margin-top:12px">${state.ollama ? '' : `<div class="card"><b>Consultant Ollama…</b></div>`}</div>`;
1593
1684
  }
1594
1685
  function pctOf(j) { return j.total ? Math.min(100, Math.round((j.completed / j.total) * 100)) : 0; }
1686
+ const OLLAMA_START = 'Engega Ollama en aquest ordinador: comprova que està instal·lat (si no, digue-m’ho abans d’instal·lar res) i executa «ollama serve» en segon pla. Quan respongui a http://localhost:11434, confirma-m’ho.';
1595
1687
  function paintModels() {
1596
1688
  const box = $('#modelsBody'); if (!box || !state.ollama) return;
1597
1689
  const o = state.ollama, have = new Set(o.models.map((m) => m.name));
1598
1690
  if (!o.available) {
1599
- box.innerHTML = `<div class="panel empty">${bot('sleep')}<h2>Ollama no està engegat en aquest ordinador</h2><p>Instal·la’l des d’<a href="https://ollama.com" target="_blank" rel="noopener">ollama.com</a> i engega’l amb <span class="mono">ollama serve</span>. Després, torna-ho a comprovar.</p><button class="btn" data-act="ollama-refresh" type="button">${svg(ICON.refresh)}Torna-ho a comprovar</button></div>`;
1691
+ box.innerHTML = `<div class="card"><div class="row between"><b>Ollama no està engegat</b><i class="dot"></i></div><div class="muted small" style="margin-top:4px">Un agent pot engegar-lo per tu amb <span class="mono">ollama serve</span>. Si no el tens, l’instal·les des d’<a href="https://ollama.com" target="_blank" rel="noopener">ollama.com</a>.</div><div class="row" style="margin-top:10px"><button class="btn ink" data-act="ollama-start" type="button">Engega’l amb un agent</button><button class="btn" data-act="ollama-refresh" type="button">Torna-ho a comprovar</button></div></div>`;
1600
1692
  return;
1601
1693
  }
1602
1694
  const pulling = o.pulling || [];
1603
- box.innerHTML = `<div class="grid2">
1604
- <div class="stack">
1605
- <section class="panel"><header><h2>Instal·lats</h2><span class="muted small">${o.models.length}</span></header><div class="body">${o.models.length ? o.models.map((m) => `
1606
- <div class="agent-row"><span class="grow"><div class="row"><b class="mono" style="font-size:14px">${esc(m.name)}</b>${(o.running || []).includes(m.name) ? '<span class="chip waiting">A memòria</span>' : ''}</div><div class="muted small">${[m.params, m.quant, bytes(m.size)].filter(Boolean).map(esc).join(' · ')}</div></span>
1607
- <button class="btn sm" data-act="ollama-chat" data-name="${esc(m.name)}" type="button">Sessió</button><button class="icon-btn" data-act="ollama-del" data-name="${esc(m.name)}" type="button" aria-label="Esborra ${esc(m.name)}">${svg(ICON.trash)}</button></div>`).join('') : '<p class="muted small">Encara no n’hi ha cap. Tria’n un de la llista o escriu-ne el nom.</p>'}</div></section>
1608
- ${pulling.length ? `<section class="panel"><header><h2>Descarregant</h2></header><div class="body stack">${pulling.map((j) => `
1609
- <div class="stack" data-pull="${esc(j.name)}"><div class="row between"><b class="mono" style="font-size:14px">${esc(j.name)}</b><button class="btn sm ghost" data-act="ollama-cancel" data-name="${esc(j.name)}" type="button">Cancel·la</button></div><div class="progress"><i style="width:${pctOf(j)}%"></i></div><div class="muted small mono" data-st>${esc(j.status)}${j.total ? ` · ${bytes(j.completed)} de ${bytes(j.total)}` : ''}</div></div>`).join('')}</div></section>` : ''}
1610
- </div>
1611
- <div class="stack">
1612
- <section class="panel"><header><h2>Descarrega un model</h2></header><div class="body stack">
1613
- <form class="row" id="pullForm" style="flex-wrap:nowrap"><input type="text" id="pullIn" placeholder="Nom del model, p. ex. qwen3:4b" autocapitalize="off" spellcheck="false" aria-label="Nom del model"><button class="btn ink" type="submit">${svg(ICON.download)}Descarrega</button></form>
1614
- <p class="muted small">Tens tots els noms a <a href="https://ollama.com/library" target="_blank" rel="noopener">ollama.com/library</a>. Les mides són aproximades.</p>
1615
- <div class="list">${OLLAMA_PICKS.map((p) => { const busy = pulling.some((j) => j.name === p.name), got = have.has(p.name); return `
1616
- <button class="card" type="button" ${got || busy ? 'disabled' : `data-act="ollama-pull" data-name="${p.name}"`} style="${got ? 'opacity:.6' : ''}"><div class="row between"><b class="mono" style="font-size:14px">${p.name}</b><span class="muted small">${got ? 'Instal·lat' : busy ? 'Descarregant…' : p.size}</span></div><div class="muted small" style="margin-top:4px">${p.note}</div></button>`; }).join('')}</div>
1617
- </div></section>
1618
- </div>
1695
+ box.innerHTML = `<div class="stack-lg">
1696
+ <div class="list">${o.models.length ? o.models.map((m) => `<div class="card model-card"><div class="row between"><b class="mono" style="font-size:14px">${esc(m.name)}</b><span class="row" style="gap:6px">${(o.running || []).includes(m.name) ? '<span class="chip waiting">A memòria</span>' : ''}<button class="btn sm" data-act="ollama-chat" data-name="${esc(m.name)}" type="button">Xat</button><button class="icon-btn" data-act="ollama-del" data-name="${esc(m.name)}" type="button" aria-label="Esborra ${esc(m.name)}">${svg(ICON.trash)}</button></span></div><div class="muted small" style="margin-top:4px">${[m.params, m.quant, bytes(m.size)].filter(Boolean).map(esc).join(' · ')}</div></div>`).join('') : '<p class="muted small">Encara no n’hi ha cap model. Tria’n un de sota.</p>'}</div>
1697
+ ${pulling.map((j) => `<div class="card stack" data-pull="${esc(j.name)}"><div class="row between"><b class="mono" style="font-size:14px">${esc(j.name)}</b><button class="btn sm ghost" data-act="ollama-cancel" data-name="${esc(j.name)}" type="button">Cancel·la</button></div><div class="progress"><i style="width:${pctOf(j)}%"></i></div><div class="muted small mono" data-st>${esc(j.status)}${j.total ? ` · ${bytes(j.completed)} de ${bytes(j.total)}` : ''}</div></div>`).join('')}
1698
+ <details class="card"><summary><b>Descarrega un model</b></summary><div class="stack" style="margin-top:10px">
1699
+ <form class="row" id="pullForm" style="flex-wrap:nowrap"><input type="text" id="pullIn" placeholder="Nom, p. ex. qwen3:4b" autocapitalize="off" spellcheck="false" aria-label="Nom del model"><button class="btn ink" type="submit">${svg(ICON.download)}Descarrega</button></form>
1700
+ <div class="list">${OLLAMA_PICKS.map((p) => { const busy = pulling.some((j) => j.name === p.name), got = have.has(p.name); return `<button class="card" type="button" ${got || busy ? 'disabled' : `data-act="ollama-pull" data-name="${p.name}"`} style="${got ? 'opacity:.6' : ''}"><div class="row between"><b class="mono" style="font-size:14px">${p.name}</b><span class="muted small">${got ? 'Instal·lat' : busy ? 'Descarregant…' : p.size}</span></div><div class="muted small" style="margin-top:4px">${p.note}</div></button>`; }).join('')}</div>
1701
+ <p class="muted small">Tots els noms a <a href="https://ollama.com/library" target="_blank" rel="noopener">ollama.com/library</a>.</p>
1702
+ </div></details>
1619
1703
  </div>`;
1620
1704
  }
1621
1705
  const pullDone = new Set(); // descàrregues que han acabat abans que respongués la petició
@@ -1660,6 +1744,7 @@ window.NioBrains = {
1660
1744
  list() {
1661
1745
  const out = [{ id: 'rules', label: 'Ordres ràpides' }];
1662
1746
  if (machine() && state.connected) (state.ollama?.models || []).forEach((m) => out.push({ id: 'ollama:' + m.name, label: `Ollama · ${m.name}` }));
1747
+ if (machine() && state.connected) (state.info?.agents || []).filter((x) => x.available && !['ollama', 'demo'].includes(x.id)).forEach((x) => out.push({ id: 'agent:' + x.id, label: `${AGENT_NAMES[x.id] || x.id} (agent)` }));
1663
1748
  if (local.engine) out.push({ id: 'local', label: `Xat local · ${LOCAL_MODELS.find((x) => x.id === local.model)?.name || local.model}` });
1664
1749
  return out;
1665
1750
  },
@@ -1668,6 +1753,22 @@ window.NioBrains = {
1668
1753
  if (!machine()) throw new Error('No hi ha cap ordinador connectat');
1669
1754
  return (await api('/api/ollama/chat', { method: 'POST', body: { model: id.slice(7), messages, format: 'json' } })).content;
1670
1755
  }
1756
+ if (id.startsWith('agent:')) {
1757
+ if (!machine()) throw new Error('No hi ha cap ordinador connectat');
1758
+ const cwd = (await api('/api/projects').catch(() => []))[0]?.path;
1759
+ const prompt = messages.map((m) => (m.role === 'system' ? m.content : m.content)).join('\n\n') + '\n\nRespon només amb el JSON demanat, sense usar cap eina.';
1760
+ const s = await api('/api/sessions', { method: 'POST', body: { agent: id.slice(6), cwd, mode: 'llegir', title: 'Pissarra', text: prompt } });
1761
+ try {
1762
+ for (let i = 0; i < 120; i++) {
1763
+ await new Promise((r) => setTimeout(r, 1500));
1764
+ const d = await api(`/api/sessions/${s.id}`);
1765
+ if (d.session.state === 'error') throw new Error(d.session.error || 'L’agent ha fallat');
1766
+ if (d.session.state === 'waiting') { api(`/api/sessions/${s.id}/stop`, { method: 'POST' }).catch(() => {}); throw new Error('L’agent demana permís per a una eina'); }
1767
+ if (d.session.state === 'idle') { const t = d.events.filter((e) => e.kind === 'text').map((e) => e.text).join('\n'); if (t) return t; }
1768
+ }
1769
+ throw new Error('L’agent ha tardat massa');
1770
+ } finally { api(`/api/sessions/${s.id}`, { method: 'DELETE' }).then(() => handle({ type: 'removed', session: s.id })).catch(() => {}); }
1771
+ }
1671
1772
  if (id === 'local') {
1672
1773
  if (!local.engine) throw new Error('Primer obre un model a «Xat local»');
1673
1774
  try {
@@ -1712,11 +1813,11 @@ const LOCAL_MODELS = [
1712
1813
  { id: 'Qwen3-1.7B-q4f16_1-MLC', name: 'Qwen 3 · 1,7B', size: '≈ 1,3 GB', note: 'Va bé amb codi i raonaments curts.' },
1713
1814
  { id: 'Phi-3.5-mini-instruct-q4f16_1-MLC', name: 'Phi 3.5 mini · 3,8B', size: '≈ 2,2 GB', note: 'El més potent. Millor en tauleta o ordinador.' }
1714
1815
  ];
1715
- const local = { engine: null, model: null, loading: false, progress: 0, status: '', messages: [], busy: false };
1816
+ const local = { ollama: null, engine: null, model: null, loading: false, progress: 0, status: '', messages: [], busy: false };
1716
1817
  const stripThink = (t) => t.replace(/<think>[\s\S]*?(<\/think>|$)/g, '').trimStart();
1717
1818
  function viewLocal() {
1718
1819
  const hasGpu = 'gpu' in navigator, chosen = local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id);
1719
- if (!local.engine) {
1820
+ if (!inLocalChat()) {
1720
1821
  return `<div class="top"><div><h1>Xat local</h1><p>Un model dins d’aquest dispositiu, per quan no tens l’ordinador a mà. Un cop descarregat, funciona sense connexió i res no surt del dispositiu.</p></div></div>
1721
1822
  <h2 class="sec" style="margin-top:0">En aquest dispositiu</h2>
1722
1823
  <div class="stack-lg" style="max-width:640px">
@@ -1726,14 +1827,15 @@ function viewLocal() {
1726
1827
  </div>
1727
1828
  ${ollamaSection()}`;
1728
1829
  }
1729
- const info = LOCAL_MODELS.find((m) => m.id === local.model);
1830
+ const info = local.ollama ? { name: local.ollama } : LOCAL_MODELS.find((m) => m.id === local.model);
1730
1831
  return `<section class="chat">
1731
- <div class="chat-head"><span>${bot('idle')}</span><div class="grow"><h1>${esc(info?.name || local.model)}</h1><div class="sub">Model dins d’aquest dispositiu</div></div></div>
1732
- <div class="mode-bar"><span class="chip"><span class="agent-tag" data-agent="browser">${esc(info?.name || local.model)}</span></span><span class="chip">Al dispositiu</span><button class="chip" type="button" data-act="lm-change">Canvia de model</button><button class="chip" type="button" data-act="lm-clear">Comença de nou</button></div>
1832
+ <div class="chat-head"><span>${bot('idle')}</span><div class="grow"><h1>${esc(info?.name || local.model)}</h1><div class="sub">${local.ollama ? `Ollama a ${esc(machine()?.name || 'l’ordinador')}` : 'Model dins d’aquest dispositiu'}</div></div></div>
1833
+ <div class="mode-bar"><span class="chip"><span class="agent-tag" data-agent="browser">${esc(info?.name || local.model)}</span></span><span class="chip">${local.ollama ? 'A l’ordinador' : 'Al dispositiu'}</span><button class="chip" type="button" data-act="lm-change">Canvia de model</button><button class="chip" type="button" data-act="lm-clear">Comença de nou</button></div>
1733
1834
  <div class="feed" id="lfeed">${local.messages.length ? '' : `<div class="empty">${bot('idle')}<h2>Pregunta el que vulguis</h2><p>Va bé per resumir, reescriure textos curts o buscar idees.</p></div>`}</div>
1734
1835
  <div class="composer"><div class="inner"><textarea rows="1" id="lmsg" placeholder="Escriu al model…" enterkeyhint="send" aria-label="Missatge per al model"></textarea><button class="send" id="lsend" type="button" aria-label="Envia">${svg(ICON.send)}</button></div></div>
1735
1836
  </section>`;
1736
1837
  }
1838
+ const inLocalChat = () => !!(local.engine || local.ollama);
1737
1839
  function bindLocal() {
1738
1840
  const feed = $('#lfeed'); if (!feed) return;
1739
1841
  for (const m of local.messages) feed.appendChild(m.role === 'user' ? el(`<div class="msg-user">${esc(m.content)}</div>`) : el(`<div class="msg-ai">${md(stripThink(m.content))}</div>`));
@@ -1768,6 +1870,7 @@ async function recoverLocal(text) {
1768
1870
  await loadLocal(id);
1769
1871
  }
1770
1872
  async function loadLocal(id) {
1873
+ local.ollama = null;
1771
1874
  local.loading = true; local.progress = 0; local.status = 'Preparant…'; render();
1772
1875
  try {
1773
1876
  const webllm = await import('https://esm.run/@mlc-ai/web-llm@0.2.86');
@@ -1790,6 +1893,11 @@ async function sendLocal() {
1790
1893
  const out = el('<div class="msg-ai cursor"></div>'); feed.appendChild(out); toBottom(feed);
1791
1894
  local.busy = true; local.stopped = false; setLocalBusy(true); let acc = '';
1792
1895
  try {
1896
+ if (local.ollama) {
1897
+ const r = await api('/api/ollama/chat', { method: 'POST', body: { model: local.ollama, messages: [{ role: 'system', content: LOCAL_SYSTEM }, ...local.messages.slice(-12)] } });
1898
+ if (!local.stopped) acc = r.content || '';
1899
+ out.innerHTML = md(stripThink(acc)); toBottom(feed);
1900
+ } else {
1793
1901
  const chunks = await local.engine.chat.completions.create({
1794
1902
  messages: [{ role: 'system', content: LOCAL_SYSTEM }, ...local.messages.slice(-8)], stream: true,
1795
1903
  temperature: 0.7, top_p: 0.8, frequency_penalty: 0.6, presence_penalty: 0.4, repetition_penalty: 1.15, max_tokens: 700,
@@ -1799,12 +1907,13 @@ async function sendLocal() {
1799
1907
  acc += c.choices[0]?.delta?.content || ''; out.innerHTML = md(stripThink(acc)); if (nearBottom(feed)) toBottom(feed);
1800
1908
  if (local.stopped) break;
1801
1909
  }
1910
+ }
1802
1911
  } catch (e) {
1803
1912
  if (gpuLost(e) && !acc) { local.messages.pop(); out.remove(); feed.lastElementChild?.remove(); local.busy = false; local.stopped = false; setLocalBusy(false); return recoverLocal(text); }
1804
1913
  if (!local.stopped) acc = acc || `Error: ${e.message}`; out.innerHTML = md(stripThink(acc));
1805
1914
  }
1806
1915
  out.classList.remove('cursor');
1807
- if (local.stopped) { try { await local.engine.interruptGenerate(); } catch { /* res */ } }
1916
+ if (local.stopped && local.engine) { try { await local.engine.interruptGenerate(); } catch { /* res */ } }
1808
1917
  if (gpuLost(acc.replace(/^Error: /, ''))) { local.messages.push({ role: 'assistant', content: 'La GPU del mòbil s’ha reiniciat enmig de la resposta.' }); local.busy = false; local.stopped = false; setLocalBusy(false); return recoverLocal(''); }
1809
1918
  local.messages.push({ role: 'assistant', content: stripThink(acc) || '…' }); local.busy = false; local.stopped = false; setLocalBusy(false);
1810
1919
  }
@@ -1815,9 +1924,7 @@ const controlOn = () => !!(state.info?.control?.navegador?.enabled || state.info
1815
1924
  // Aturada d'emergència: sempre a la vista mentre el control estigui actiu.
1816
1925
  function renderPanic() {
1817
1926
  syncScreenBtn();
1818
- let b = $('#panicBtn');
1819
- if (!controlOn() || !machine()) { b?.remove(); return; }
1820
- if (!b) { b = el(`<button class="panic" id="panicBtn" type="button" data-act="panic" aria-label="Aturada d’emergència">${svg(ICON.panic)}<span>Atura-ho tot</span></button>`); document.body.appendChild(b); }
1927
+ $('#panicBtn')?.remove();
1821
1928
  }
1822
1929
  async function panicNow() {
1823
1930
  if (!confirm('S’aturaran tots els agents d’aquest ordinador, es rebutjaran les peticions pendents i es tancarà el navegador.')) return;
@@ -1862,7 +1969,7 @@ async function loadScreen() {
1862
1969
  function syncScreenBtn() { const b = $('[data-act=screen-now]'); if (b) b.disabled = !controlOn(); }
1863
1970
  function onScreen(msg) { if (screen && !screen.error) { screen.last = msg.shot; { const box = $('#screenBody'); if (box) box.innerHTML = screenHtml(); hydrateShots(box); } } }
1864
1971
  /* ---------------------------------------------------------------- pantalla remota (veure i controlar l'ordinador) */
1865
- const RD = { on: false, ctl: false, mods: new Set(), drag: false, q: 55, blank: 0, err: 0, objUrl: null, chain: Promise.resolve(), sw: 0, tap: null, scroll: null, errAt: 0 };
1972
+ const RD = { on: false, ctl: false, mods: new Set(), drag: false, q: 55, blank: 0, err: 0, hash: '', still: 0, scale: 1, objUrl: null, chain: Promise.resolve(), sw: 0, tap: null, scroll: null, errAt: 0 };
1866
1973
  const RD_KEYS = [['Escape', 'Esc'], ['Tab', 'Tab'], ['ArrowUp', '↑'], ['ArrowDown', '↓'], ['ArrowLeft', '←'], ['ArrowRight', '→'], ['Enter', '⏎'], ['Backspace', '⌫']];
1867
1974
  const RD_MODS = [['meta', '⌘'], ['alt', '⌥'], ['ctrl', '⌃'], ['shift', '⇧']];
1868
1975
  const RD_SPECIAL = /^(Escape|Tab|Arrow(Up|Down|Left|Right)|Home|End|PageUp|PageDown|Delete|F([1-9]|1[0-2]))$/;
@@ -1870,12 +1977,13 @@ const RD_SPECIAL = /^(Escape|Tab|Arrow(Up|Down|Left|Right)|Home|End|PageUp|PageD
1870
1977
  async function remoteOpen() {
1871
1978
  const m = machine(); if (!m || RD.on) return;
1872
1979
  if (!(state.info?.control?.escriptori?.enabled || screen?.caps?.escriptori?.enabled)) return toast('Activa primer «Apps de l’escriptori» en aquest ordinador.', 5000);
1873
- Object.assign(RD, { on: true, ctl: false, mods: new Set(), drag: false, blank: 0, err: 0, tap: null, scroll: null });
1980
+ const slow = navigator.connection && (navigator.connection.saveData || /^(slow-2g|2g|3g)$/.test(navigator.connection.effectiveType || '') || navigator.connection.type === 'cellular');
1981
+ Object.assign(RD, { hash: '', still: 0, scale: slow ? .6 : 1, q: slow ? 45 : 55, two: 'scroll', z: 1, px: 0, py: 0, on: true, ctl: false, mods: new Set(), drag: false, blank: 0, err: 0, tap: null, scroll: null });
1874
1982
  const root = document.createElement('div');
1875
1983
  root.className = 'rd'; root.id = 'rd'; root.setAttribute('role', 'dialog'); root.setAttribute('aria-label', 'Pantalla remota');
1876
1984
  root.innerHTML = `<div class="rd-bar"><button class="rd-btn" data-rd="close" type="button">Tanca</button><b class="rd-title">${esc(m.name || 'Ordinador')}</b><span class="rd-stat" id="rdStat"></span><span class="grow"></span><button class="rd-btn" data-rd="full" type="button" id="rdFull" hidden>Pantalla completa</button><div class="rd-seg" role="group" aria-label="Mode"><button data-rd="view" type="button" aria-pressed="true">Mirar</button><button data-rd="ctl" type="button" aria-pressed="false">Controlar</button></div></div>
1877
- <div class="rd-stage" id="rdStage"><img id="rdImg" alt="Pantalla de l’ordinador" draggable="false"><div class="rd-msg" id="rdMsg">Connectant…</div></div>
1878
- <div class="rd-keys" id="rdKeys" hidden><button class="rd-btn" data-rd="kbd" type="button">Teclat</button>${RD_MODS.map(([k, l]) => `<button class="rd-btn" data-mod="${k}" type="button" aria-pressed="false">${l}</button>`).join('')}${RD_KEYS.map(([k, l]) => `<button class="rd-btn" data-key="${k}" type="button">${l}</button>`).join('')}<button class="rd-btn" data-rd="drag" type="button" id="rdDrag">1 dit: desplaça</button></div>
1985
+ <div class="rd-stage" id="rdStage"><img id="rdImg" alt="Pantalla de l’ordinador" draggable="false"><div class="rd-msg" id="rdMsg">Connectant…</div><div class="rd-tools" role="group" aria-label="Zoom i captura"><button class="rd-btn" data-rd="zout" type="button" aria-label="Allunya">−</button><button class="rd-btn rd-pct" data-rd="zfit" type="button" id="rdPct" aria-label="Enquadra">100%</button><button class="rd-btn" data-rd="zin" type="button" aria-label="Apropa">+</button><button class="rd-btn" data-rd="z11" type="button" aria-label="Mida real">1:1</button><span class="rd-sep"></span><button class="rd-btn" data-rd="save" type="button">Desa captura</button></div></div>
1986
+ <div class="rd-keys" id="rdKeys" hidden><button class="rd-btn" data-rd="kbd" type="button">Teclat</button>${RD_MODS.map(([k, l]) => `<button class="rd-btn" data-mod="${k}" type="button" aria-pressed="false">${l}</button>`).join('')}${RD_KEYS.map(([k, l]) => `<button class="rd-btn" data-key="${k}" type="button">${l}</button>`).join('')}<button class="rd-btn" data-rd="drag" type="button" id="rdDrag">1 dit: desplaça</button><button class="rd-btn" data-rd="two" type="button" id="rdTwo">2 dits: desplaça</button></div>
1879
1987
  <textarea class="rd-type" id="rdType" autocapitalize="off" autocorrect="off" autocomplete="off" spellcheck="false" aria-label="Teclat de l’ordinador"></textarea>`;
1880
1988
  document.body.appendChild(root);
1881
1989
  document.documentElement.classList.add('rd-open');
@@ -1912,11 +2020,21 @@ async function rdLoop() {
1912
2020
  const started = performance.now();
1913
2021
  try {
1914
2022
  const stage = $('#rdStage'); if (!stage) break;
1915
- const w = Math.min(1800, Math.max(640, Math.round(stage.clientWidth * Math.min(devicePixelRatio || 1, 2))));
1916
- const res = await fetch(`${m.url}/api/desktop/frame?w=${w}&q=${RD.q}&_=${Date.now()}`, { headers: { Authorization: `Bearer ${m.token}` }, cache: 'no-store' });
2023
+ const w = Math.min(2400, Math.max(480, Math.round(stage.clientWidth * Math.min(devicePixelRatio || 1, 2) * Math.min(RD.z, 2) * RD.scale)));
2024
+ const res = await fetch(`${m.url}/api/desktop/frame?w=${w}&q=${RD.q}&h=${RD.hash}&_=${Date.now()}`, { headers: { Authorization: `Bearer ${m.token}` }, cache: 'no-store' });
1917
2025
  if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.error || `Error ${res.status}`); }
2026
+ const t1 = performance.now();
2027
+ if (res.status === 204) { RD.still++; RD.err = 0; n++; await sleep(RD.still > 3 ? 320 : 120); continue; }
1918
2028
  const blob = await res.blob();
1919
2029
  if (!RD.on) break;
2030
+ RD.still = 0; RD.hash = res.headers.get('x-hash') || '';
2031
+ const dl = performance.now() - t1;
2032
+ // Si baixar el fotograma triga massa (dades, una altra xarxa), l'abaixa de mida i qualitat; si va sobrat, el torna a pujar.
2033
+ if (blob.size > 12000) {
2034
+ if (dl > 450) { if (RD.scale > .4) RD.scale = Math.max(.4, RD.scale * .8); else RD.q = Math.max(32, RD.q - 6); }
2035
+ else if (dl < 140 && started - (RD.adj || 0) > 1200) { if (RD.q < 55) RD.q = Math.min(55, RD.q + 4); else RD.scale = Math.min(1, RD.scale * 1.12); RD.adj = started; }
2036
+ if (dl > 450) RD.adj = started;
2037
+ }
1920
2038
  const img = $('#rdImg'), old = RD.objUrl, url = URL.createObjectURL(blob);
1921
2039
  RD.objUrl = url; img.onload = () => { if (old) URL.revokeObjectURL(old); }; img.src = url;
1922
2040
  RD.sw = Number(res.headers.get('x-screen-w')) || RD.sw;
@@ -1929,7 +2047,7 @@ async function rdLoop() {
1929
2047
  RD.err++; rdMsg(RD.err > 1 ? `${e.message || 'Sense connexió'}${/^Error|Activa|captura/.test(e.message) ? '' : ' · reconnectant…'}` : '');
1930
2048
  await sleep(Math.min(3000, 500 * RD.err));
1931
2049
  }
1932
- await sleep(Math.max(30, 110 - (performance.now() - started)));
2050
+ await sleep(Math.max(0, 70 - (performance.now() - started)));
1933
2051
  }
1934
2052
  }
1935
2053
  function rdSend(ev) {
@@ -1958,9 +2076,78 @@ function rdScroll(dx, dy, pos) {
1958
2076
  if (ev.dx || ev.dy) rdSend(ev);
1959
2077
  }, 70);
1960
2078
  }
2079
+
2080
+ function rdView() {
2081
+ const img = $('#rdImg'), st = $('#rdStage'); if (!img || !st) return;
2082
+ const z = RD.z, mx = Math.max(0, (img.offsetWidth * z - st.clientWidth) / 2), my = Math.max(0, (img.offsetHeight * z - st.clientHeight) / 2);
2083
+ RD.px = Math.max(-mx, Math.min(mx, RD.px)); RD.py = Math.max(-my, Math.min(my, RD.py));
2084
+ if (z === 1) RD.px = RD.py = 0;
2085
+ img.style.transform = z === 1 ? '' : `translate(${RD.px}px,${RD.py}px) scale(${z})`;
2086
+ const pct = $('#rdPct'); if (pct) pct.textContent = Math.round(z * 100) + '%';
2087
+ }
2088
+ function rdZoom(z, cx, cy) {
2089
+ const st = $('#rdStage'); if (!st) return;
2090
+ z = Math.max(1, Math.min(6, z));
2091
+ const r = st.getBoundingClientRect(), ox = (cx ?? r.left + r.width / 2) - (r.left + r.width / 2), oy = (cy ?? r.top + r.height / 2) - (r.top + r.height / 2), k = z / RD.z;
2092
+ RD.px = ox - (ox - RD.px) * k; RD.py = oy - (oy - RD.py) * k; RD.z = z; rdView();
2093
+ }
2094
+ async function screenNow() {
2095
+ const m = machine(); if (!m) return;
2096
+ toast('Fent la captura…');
2097
+ try {
2098
+ const res = await fetch(`${m.url}/api/desktop/frame?w=1600&q=80&_=${Date.now()}`, { headers: { Authorization: `Bearer ${m.token}` }, cache: 'no-store' });
2099
+ if (!res.ok) throw new Error(`Error ${res.status}`);
2100
+ const url = URL.createObjectURL(await res.blob());
2101
+ const d = new Date(), p = (n) => String(n).padStart(2, '0');
2102
+ const name = `nio-pantalla-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}.jpg`;
2103
+ openSheet(`<h2>Captura de la pantalla</h2><img class="screen-img" alt="Captura de la pantalla de l’ordinador" src="${url}" style="width:100%;border-radius:10px;margin:8px 0"><div class="row"><a class="btn ink" href="${url}" download="${name}">Desa la imatge</a><button class="btn" type="button" data-close>Tanca</button></div>`);
2104
+ } catch {
2105
+ try { const s = await api('/api/screen', { method: 'POST' }); onScreen({ shot: s }); toast('Captura feta'); } catch (err) { toast(`No s’ha pogut fer la captura: ${err.message}`, 5000); }
2106
+ }
2107
+ }
2108
+ function rdSave() {
2109
+ const img = $('#rdImg'); if (!img?.src) return toast('Encara no hi ha cap imatge');
2110
+ const d = new Date(), p = (n) => String(n).padStart(2, '0');
2111
+ const a = document.createElement('a'); a.href = img.src; a.download = `nio-pantalla-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}.jpg`;
2112
+ document.body.appendChild(a); a.click(); a.remove(); toast('Captura desada');
2113
+ }
2114
+ function rdGestures(stage) {
2115
+ const pts = new Map(); let g = null, pan = null, tapT = 0;
2116
+ const dist = () => { const [a, b] = [...pts.values()]; return Math.hypot(a.x - b.x, a.y - b.y) || 1; };
2117
+ const mid = () => { const [a, b] = [...pts.values()]; return [(a.x + b.x) / 2, (a.y + b.y) / 2]; };
2118
+ stage.addEventListener('pointerdown', (e) => {
2119
+ if (e.target.closest('.rd-tools') || (RD.ctl && (RD.two !== 'zoom' || e.pointerType === 'mouse'))) return;
2120
+ try { stage.setPointerCapture(e.pointerId); } catch {}
2121
+ pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
2122
+ if (pts.size === 2) { const [mx, my] = mid(); g = { d: dist(), z: RD.z, mx, my, px: RD.px, py: RD.py }; pan = null; }
2123
+ else if (pts.size === 1 && !RD.ctl) pan = { x: e.clientX, y: e.clientY, px: RD.px, py: RD.py };
2124
+ });
2125
+ stage.addEventListener('pointermove', (e) => {
2126
+ const p = pts.get(e.pointerId); if (!p) return; p.x = e.clientX; p.y = e.clientY;
2127
+ if (pts.size >= 2 && g) {
2128
+ const [mx, my] = mid(), st = stage.getBoundingClientRect(), z = Math.max(1, Math.min(6, g.z * dist() / g.d)), k = z / g.z;
2129
+ const ox = g.mx - (st.left + st.width / 2), oy = g.my - (st.top + st.height / 2);
2130
+ RD.z = z; RD.px = g.px * k + ox * (1 - k) + (mx - g.mx); RD.py = g.py * k + oy * (1 - k) + (my - g.my); rdView();
2131
+ } else if (pan && RD.z > 1 && !RD.ctl) { RD.px = pan.px + e.clientX - pan.x; RD.py = pan.py + e.clientY - pan.y; rdView(); }
2132
+ });
2133
+ const up = (e) => {
2134
+ if (!pts.has(e.pointerId)) return;
2135
+ const moved = pan && Math.hypot(e.clientX - pan.x, e.clientY - pan.y) > 8;
2136
+ pts.delete(e.pointerId); g = null;
2137
+ if (pts.size === 1 && !RD.ctl) { const [q] = pts.values(); pan = { x: q.x, y: q.y, px: RD.px, py: RD.py }; }
2138
+ else { pan = null; if (!pts.size && e.type === 'pointerup' && !moved && !RD.ctl) { const now = performance.now(); if (now - tapT < 320) { RD.z > 1 ? (RD.z = 1, rdView()) : rdZoom(2.5, e.clientX, e.clientY); tapT = 0; } else tapT = now; } }
2139
+ };
2140
+ stage.addEventListener('pointerup', up); stage.addEventListener('pointercancel', up);
2141
+ stage.addEventListener('wheel', (e) => { if (!(e.ctrlKey || e.metaKey) && RD.ctl) return; e.preventDefault(); if (e.ctrlKey || e.metaKey) rdZoom(RD.z * Math.exp(-Math.max(-40, Math.min(40, e.deltaY)) * 0.01), e.clientX, e.clientY); else if (RD.z > 1) { RD.px -= e.deltaX; RD.py -= e.deltaY; rdView(); } }, { passive: false });
2142
+ let gz = 1;
2143
+ stage.addEventListener('gesturestart', (e) => { e.preventDefault(); gz = RD.z; });
2144
+ stage.addEventListener('gesturechange', (e) => { e.preventDefault(); rdZoom(gz * e.scale, e.clientX, e.clientY); });
2145
+ window.addEventListener('resize', rdView);
2146
+ }
1961
2147
  function rdBind(root) {
1962
2148
  const stage = $('#rdStage', root), typer = $('#rdType', root);
1963
2149
  const ptrs = new Map();
2150
+ rdGestures(stage);
1964
2151
  const click = (pos, e) => {
1965
2152
  const now = performance.now(), prev = RD.tap, near = prev && Math.hypot(e.clientX - prev.x, e.clientY - prev.y) < 28;
1966
2153
  const count = prev && near && now - prev.t < 340 ? (prev.count >= 2 ? 3 : 2) : 1;
@@ -1968,12 +2155,13 @@ function rdBind(root) {
1968
2155
  rdSend({ type: 'click', x: pos[0], y: pos[1], count });
1969
2156
  };
1970
2157
  stage.addEventListener('pointerdown', (e) => {
1971
- if (!RD.ctl || (e.pointerType === 'mouse' && e.button !== 0)) return;
2158
+ if (!RD.ctl || e.target.closest('.rd-tools') || (e.pointerType === 'mouse' && e.button !== 0)) return;
1972
2159
  try { stage.setPointerCapture(e.pointerId); } catch {}
1973
2160
  const p = { x0: e.clientX, y0: e.clientY, x: e.clientX, y: e.clientY, touch: e.pointerType !== 'mouse', moved: false, long: false, path: [], pt: 0, lp: null };
1974
2161
  const pos = rdPos(e); if (pos) p.path.push(pos);
1975
2162
  if (p.touch) p.lp = setTimeout(() => { if (!p.moved && ptrs.size === 1 && ptrs.has(e.pointerId)) { p.long = true; navigator.vibrate?.(15); const q = rdPos(e); if (q) rdSend({ type: 'rclick', x: q[0], y: q[1] }); } }, 550);
1976
2163
  ptrs.set(e.pointerId, p);
2164
+ if (RD.two === 'zoom' && p.touch && ptrs.size > 1) for (const q of ptrs.values()) { q.moved = q.zoomed = true; clearTimeout(q.lp); }
1977
2165
  if (!typer.matches(':focus') && !p.touch) typer.focus({ preventScroll: true });
1978
2166
  e.preventDefault();
1979
2167
  });
@@ -1981,7 +2169,7 @@ function rdBind(root) {
1981
2169
  const p = ptrs.get(e.pointerId); if (!p) return;
1982
2170
  const dx = e.clientX - p.x, dy = e.clientY - p.y; p.x = e.clientX; p.y = e.clientY;
1983
2171
  if (!p.moved && Math.hypot(p.x - p.x0, p.y - p.y0) > 8) { p.moved = true; clearTimeout(p.lp); }
1984
- if (!p.moved || p.long) return;
2172
+ if (!p.moved || p.long || p.zoomed) return;
1985
2173
  if (p.touch && (ptrs.size > 1 || !RD.drag)) { if (ptrs.size > 1 && e.isPrimary === false) return; rdScroll(dx, dy, rdPos(e)); return; }
1986
2174
  const now = performance.now(), pos = rdPos(e);
1987
2175
  if (pos && now - p.pt > 22) { p.path.push(pos); p.pt = now; }
@@ -1989,7 +2177,7 @@ function rdBind(root) {
1989
2177
  const end = (e) => {
1990
2178
  const p = ptrs.get(e.pointerId); if (!p) return;
1991
2179
  ptrs.delete(e.pointerId); clearTimeout(p.lp);
1992
- if (p.long) return;
2180
+ if (p.long || p.zoomed) return;
1993
2181
  const pos = rdPos(e);
1994
2182
  if (!p.moved) { if (pos && e.type === 'pointerup') click(pos, e); return; }
1995
2183
  if (p.touch && (ptrs.size > 0 || !RD.drag)) return;
@@ -2043,6 +2231,12 @@ function rdBind(root) {
2043
2231
  rdHint(); break;
2044
2232
  }
2045
2233
  case 'kbd': typer.focus(); break;
2234
+ case 'zin': rdZoom(RD.z * 1.5); break;
2235
+ case 'zout': rdZoom(RD.z / 1.5); break;
2236
+ case 'zfit': RD.z = 1; rdView(); break;
2237
+ case 'z11': { const im = $('#rdImg'); rdZoom(RD.sw && im.offsetWidth ? RD.sw / im.offsetWidth : 2); break; }
2238
+ case 'save': rdSave(); break;
2239
+ case 'two': RD.two = RD.two === 'zoom' ? 'scroll' : 'zoom'; b.textContent = RD.two === 'zoom' ? '2 dits: zoom' : '2 dits: desplaça'; break;
2046
2240
  case 'drag': RD.drag = !RD.drag; b.textContent = RD.drag ? '1 dit: arrossega' : '1 dit: desplaça'; break;
2047
2241
  }
2048
2242
  });
@@ -2228,7 +2422,8 @@ document.addEventListener('click', async (e) => {
2228
2422
  case 'ollama-pull': return pullModel(a.dataset.name || $('#pullIn')?.value);
2229
2423
  case 'ollama-cancel': try { await api(`/api/ollama/pull/${encodeURIComponent(a.dataset.name)}`, { method: 'DELETE' }); } catch (err) { toast(err.message); } return;
2230
2424
  case 'ollama-del': return deleteModel(a.dataset.name);
2231
- case 'ollama-chat': try { state.info = await api('/api/info?refresh=1'); } catch { /* res */ } return newSessionSheet({ agent: 'ollama', model: a.dataset.name });
2425
+ case 'ollama-start': return newSessionSheet({ text: OLLAMA_START });
2426
+ case 'ollama-chat': local.ollama = a.dataset.name; local.engine = null; local.model = null; local.messages = []; return render();
2232
2427
  case 'retry': return connect();
2233
2428
  case 'model': { const s = state.sessions.get(state.route.id); if (s) modelSheet(s); return; }
2234
2429
  case 'mode': { const s = state.sessions.get(state.route.id); if (s) modeSheet(s); return; }
@@ -2240,7 +2435,7 @@ document.addEventListener('click', async (e) => {
2240
2435
  case 'job-new': return newJobSheet();
2241
2436
  case 'panic': return panicNow();
2242
2437
  case 'remote-open': remoteOpen(); return;
2243
- case 'screen-now': try { const s = await api('/api/screen', { method: 'POST' }); onScreen({ shot: s }); } catch (err) { toast(err.message, 4500); } return;
2438
+ case 'screen-now': return screenNow();
2244
2439
  case 'mem-new': return memEdit(null);
2245
2440
  case 'mem-all': if (mem) delete mem.hits; return loadMemory();
2246
2441
  case 'activity-refresh': activity = null; return loadActivity();
@@ -2249,13 +2444,14 @@ document.addEventListener('click', async (e) => {
2249
2444
  case 'open-sessions': return go('#/sessions');
2250
2445
  case 'help': return go('#/ajuda');
2251
2446
  case 'more': return moreSheet();
2447
+ case 'pantalla-tab': return pantallaTab();
2252
2448
  case 'folder': { const s = state.sessions.get(state.route.id); return s && folderSheet(s); }
2253
2449
  case 'machines': return machinesSheet();
2254
2450
  case 'mobile-qr': return mobileQrSheet();
2255
2451
  case 'mobile-hint-off': { const m = machine(); LS.set(`nio.mobHint.${m?.id || m?.url}`, true); return render(); }
2256
2452
  case 'git': return gitSheet(state.route.id);
2257
2453
  case 'lm-load': return loadLocal(local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id));
2258
- case 'lm-change': await local.engine?.unload?.(); local.engine = null; local.model = null; return render();
2454
+ case 'lm-change': await local.engine?.unload?.(); local.engine = null; local.model = null; local.ollama = null; local.messages = []; return render();
2259
2455
  case 'lm-clear': local.messages = []; return render();
2260
2456
  case 'palette': return palette();
2261
2457
  case 'tour': return tourStart();
@@ -2328,48 +2524,112 @@ $('#side').addEventListener('click', (e) => { if (e.target.closest('a, button'))
2328
2524
  addEventListener('keydown', (e) => { if (e.key === 'Escape' && document.body.classList.contains('nav-open')) setNav(false); });
2329
2525
  mq.addEventListener('change', () => setNav(false));
2330
2526
  /* ---------------------------------------------------------------- tutorial */
2331
- const TOUR_KEY = 'nio.tour.v2';
2332
- const T = Object.assign({ on: false, i: 0, seen: false }, LS.get(TOUR_KEY, {}));
2527
+ const TOUR_KEY = 'nio.tour.v3';
2528
+ const T = Object.assign({ on: false, i: 0, seen: false, busy: false, pos: null, drawn: '' }, LS.get(TOUR_KEY, {}));
2529
+ T.busy = false; T.drawn = '';
2333
2530
  const saveTour = () => LS.set(TOUR_KEY, { on: T.on, i: T.i, seen: T.seen });
2531
+ const PAGE = '.page .top, .top';
2334
2532
  const TSTEPS = [
2335
- { center: true, mood: 'happy', title: 'Hola, sóc en Nio.', text: 'Faig de pont entre el teu mòbil i els agents d’IA (Claude Code, Codex…) del teu ordinador o d’un servidor. En un minut t’ensenyo com es fa.', next: 'Comencem' },
2336
- { route: 'ordinadors', target: '#addComputer', m: '#addComputer', title: '1. Afegeix un ordinador', text: 'Hi ha tres maneres: aquest ordinador (<span class="mono">npx nio-cat</span>), un servidor per SSH (<span class="mono">usuari@servidor</span>) o un codi. nio fa la resta.' },
2337
- { route: 'ordinadors', skipIf: () => !machine(), target: '.card.computer.active .c-actions', m: '.card.computer.active .c-actions', title: '2. Continua al mòbil', text: 'A la targeta de l’ordinador prem «Continua al mòbil»: surt un QR i un codi. Al mòbil, obre www.nio.cat/app, Ordinadors, i escaneja el QR o enganxa el codi.' },
2338
- { route: 'sessions', skipIf: () => !machine(), target: '.top [data-act=new]', m: '.top [data-act=new]', title: '3. Dona feina a un agent', text: 'Una sessió és una conversa amb un agent dins d’una carpeta. Tries l’agent, la carpeta (qualsevol de l’ordinador), els permisos i li expliques què ha de fer.' },
2339
- { route: 'approvals', skipIf: () => !machine(), target: '.side nav a[href="#/aprovacions"]', m: '#tabbar a[href="#/aprovacions"]', title: '4. Tu decideixes', text: 'En mode «Demana permís», abans d’editar un fitxer o executar una ordre, l’agent t’ho pregunta aquí. Si actives els avisos, t’arriba al mòbil.' },
2340
- { route: 'mobil', target: '.side nav a[href="#/xat-local"]', m: '.page .model-card', title: 'Models sense internet', text: 'Si no tens l’ordinador a mà, pots fer servir un model petit que funciona dins del mateix mòbil. Si tens Ollama a l’ordinador, aquí hi descarregues models més grans.' },
2341
- { route: 'pissarra', target: '.side nav a[href="#/pissarra"]', m: '#bdTools', title: 'Pensa en dibuix', text: 'A la pissarra pots fer diagrames a mà alçada o demanar-me’ls escrivint, amb Ollama o amb el model del mòbil.' },
2342
- { route: 'ajuda', target: '#help', m: '#help', title: 'Si alguna cosa no va', text: 'A «Ajuda» tens cada pas explicat, què cal al servidor i què mirar si no connecta.' },
2343
- { target: '[data-act=palette]', m: '#tabbar [data-act=more]', title: () => (mq.matches ? 'Més coses' : 'Un últim truc'), text: () => (mq.matches ? 'A «Més» tens el xat local, la pissarra, els ajustos i l’ajuda, amb aquest tutorial.' : 'Prem ⌘K (o Ctrl+K) per anar a qualsevol lloc o obrir una sessió sense tocar el ratolí.') },
2344
- { center: true, mood: 'happy', title: 'Ja està tot a punt.', text: 'Si mai vols repassar-ho, tens el tutorial a Ajuda.', next: 'Finalitza' }
2533
+ { center: true, mood: 'happy', title: 'Hola, sóc en Nio.', text: 'Faig de pont entre el teu mòbil i els agents d’IA (Claude Code, Codex, Grok, Antigravity…) del teu ordinador o d’un servidor. Et faig un recorregut per tot nio, amb un clic a cada apartat. Dura uns dos minuts.', next: 'Comencem' },
2534
+ { route: 'ordinadors', target: '#addComputer', m: '#addComputer', title: 'Afegeix un ordinador', text: 'Hi ha tres maneres: aquest ordinador (<span class="mono">npx nio-cat</span>), un servidor per SSH (<span class="mono">usuari@servidor</span>) o un codi. nio fa la resta.' },
2535
+ { route: 'ordinadors', skipIf: () => !machine(), target: '.card.computer.active .c-actions', m: '.card.computer.active .c-actions', title: 'Continua al mòbil', text: 'A la targeta de l’ordinador prem «Continua al mòbil»: surt un QR i un codi. Al mòbil, obre www.nio.cat/app, Ordinadors, i escaneja el QR o enganxa el codi.' },
2536
+ { route: 'ordinadors', skipIf: () => !machine(), target: '#screenSec', m: '#screenSec', title: 'Mira i controla la pantalla', text: 'Aquí actives «Apps de l’escriptori» i el navegador dels agents. Amb l’escriptori actiu pots veure la pantalla de l’ordinador i fer-hi captures des del mòbil.' },
2537
+ { route: 'ordinadors', mobileOnly: true, tapOnly: '#tabbar [data-act=pantalla-tab]', target: '#tabbar [data-act=pantalla-tab]', m: '#tabbar [data-act=pantalla-tab]', title: 'Pantalla, a un toc', text: 'La pestanya «Pantalla» obre l’escriptori de l’ordinador al mòbil, com un escriptori remot, amb zoom. Si encara no està activat, t’ajuda a activar-lo.' },
2538
+ { route: 'sessions', skipIf: () => !machine(), target: '.top [data-act=new]', m: '.top [data-act=new]', title: 'Dona feina a un agent', text: 'Una sessió és una conversa amb un agent dins d’una carpeta. Tries l’agent, la carpeta (les preferides o la que vulguis), els permisos i li expliques què ha de fer.' },
2539
+ { route: 'sessions', skipIf: () => !machine(), target: '.session-card, .panel.empty', m: '.session-card, .panel.empty', title: 'Les teves sessions', text: 'Cada targeta té el robot del color de l’agent i diu què està fent: treballant, esperant-te o acabat. Toca’n una per continuar la conversa.' },
2540
+ { route: 'approvals', skipIf: () => !machine(), target: PAGE, m: PAGE, title: 'Tu decideixes', text: 'En mode «Demana permís», abans d’editar un fitxer o executar una ordre, l’agent t’ho pregunta aquí. Al mòbil hi arribes amb la campana de dalt, i si actives els avisos t’arriba la notificació.' },
2541
+ { route: 'feines', skipIf: () => !machine(), target: PAGE, m: PAGE, title: 'Feines programades', text: 'Programa que un agent faci una cosa per tu: d’aquí a una estona, cada dia o quan toqui. Tries l’agent, la carpeta i el moment, com un temporitzador.' },
2542
+ { route: 'memoria', skipIf: () => !machine(), target: PAGE, m: PAGE, title: 'Memòria compartida', text: 'Notes en text pla que tots els agents poden llegir i escriure: preferències, decisions, context dels projectes. Així no repeteixes res a cada agent.' },
2543
+ { route: 'mobil', target: PAGE, m: PAGE, title: 'Xat local', text: 'Un xat senzill amb un model que funciona dins del mòbil (sense internet) o amb els models d’Ollama de l’ordinador. No toca cap carpeta: només preguntes i respostes.' },
2544
+ { route: 'pissarra', target: '#bdTools', m: '#bdTools', title: 'Pensa en dibuix', text: 'A la pissarra pots fer diagrames a mà alçada o demanar-me’ls escrivint, amb Ollama, amb un agent o amb el model del mòbil.' },
2545
+ { route: 'activitat', skipIf: () => !machine(), target: PAGE, m: PAGE, title: 'Activitat', text: 'El registre de tot el que han fet els agents i de tot el que has aprovat o rebutjat, perquè sempre sàpigues què ha passat.' },
2546
+ { route: 'ajustos', target: PAGE, m: PAGE, title: 'Ajustos', text: 'Aspecte, avisos, Telegram i les opcions de l’ordinador. Tot el que canvia el comportament de nio és aquí.' },
2547
+ { route: 'ajuda', target: '#help', m: '#help', title: 'Si alguna cosa no va', text: 'A «Ajuda» tens cada pas explicat, què cal al servidor i què mirar si no connecta. També hi pots repetir aquest tutorial.' },
2548
+ { route: 'sessions', desktopOnly: true, target: '[data-act=palette]', m: '[data-act=palette]', title: 'Un últim truc', text: 'Prem ⌘K (o Ctrl+K) per anar a qualsevol lloc o obrir una sessió sense tocar el ratolí.' },
2549
+ { center: true, route: 'sessions', mood: 'happy', title: 'Ja està tot a punt.', text: 'Si mai vols repassar-ho, tens el tutorial a Ajuda.', next: 'Finalitza' }
2345
2550
  ];
2346
2551
  const tval = (v) => (typeof v === 'function' ? v() : v);
2347
- function tourStart() { T.on = true; T.seen = true; tourGo(0); }
2348
- function tourEnd(silent) { T.on = false; T.seen = true; saveTour(); $('#tour').hidden = true; if (!silent) toast('Tutorial tancat. El tens al menú quan vulguis.'); }
2349
- function tourGo(i) {
2350
- while (i < TSTEPS.length && TSTEPS[i].skipIf?.()) i++;
2552
+ const tskip = (st) => st.skipIf?.() || (st.mobileOnly && !mq.matches) || (st.desktopOnly && mq.matches);
2553
+ const tpause = (ms) => new Promise((ok) => setTimeout(ok, reduce ? 0 : ms));
2554
+ const twait = (find, ms = 3000) => new Promise((done) => { const t0 = performance.now(); const tick = () => { const e = find(); if (e || performance.now() - t0 > ms) done(e || null); else setTimeout(tick, 70); }; tick(); });
2555
+ const tvisible = (sel) => { const e = sel && document.querySelector(sel); return e && e.getClientRects().length && !e.closest('[inert]') ? e : null; };
2556
+ let tourRun = 0;
2557
+ function tourStart() { T.on = true; T.seen = true; $('#tour').hidden = false; tourGo(0); }
2558
+ function tourEnd(silent) { tourRun++; T.on = false; T.seen = true; T.busy = false; saveTour(); $('#tourTap').classList.remove('on', 'press'); $('#tour').hidden = true; if (!silent) toast('Tutorial tancat. El tens a Ajuda quan vulguis.'); }
2559
+ // El control que tocaria una persona per arribar a l'apartat: menú lateral, pestanya, campana o «Més».
2560
+ function tourVia(route) {
2561
+ const h = HASH[route];
2562
+ if (!mq.matches) return tvisible(`.side nav a[href="${h}"]`);
2563
+ return tvisible(`#tabbar a[href="${h}"]`) || (route === 'approvals' ? tvisible(`.mbar a[href="${h}"]`) : null) || tvisible('#tabbar [data-act=more]');
2564
+ }
2565
+ // Un cercle es mou fins al control i el prem, com un clic de veritat.
2566
+ async function tourTap(e, run) {
2567
+ const dot = $('#tourTap'), r = e.getBoundingClientRect();
2568
+ const x = r.left + Math.min(r.width / 2, 60), y = r.top + r.height / 2;
2569
+ if (!T.pos) { dot.classList.add('nomove'); dot.style.translate = `${innerWidth / 2}px ${innerHeight - 140}px`; void dot.offsetWidth; dot.classList.remove('nomove'); }
2570
+ dot.classList.add('on');
2571
+ dot.style.translate = `${x}px ${y}px`; T.pos = [x, y];
2572
+ await tpause(620); if (run !== tourRun) return false;
2573
+ dot.classList.add('press'); e.classList.add('tour-pressed');
2574
+ await tpause(190); if (run !== tourRun) return false;
2575
+ dot.classList.remove('press'); e.classList.remove('tour-pressed');
2576
+ return true;
2577
+ }
2578
+ async function tourGo(i) {
2579
+ const run = ++tourRun;
2580
+ while (i < TSTEPS.length && tskip(TSTEPS[i])) i++;
2351
2581
  if (i >= TSTEPS.length) return tourEnd(true);
2352
2582
  T.i = Math.max(0, i); saveTour();
2353
- const st = TSTEPS[T.i];
2354
- if (st.route && state.route.name !== st.route) { setTimeout(() => tourDraw(true), 80); go(HASH[st.route]); }
2355
- else tourDraw(true);
2583
+ const st = TSTEPS[T.i], card = $('#tourCard'), spot = $('#tourSpot'), dot = $('#tourTap');
2584
+ $('#tour').hidden = false;
2585
+ T.busy = true; card.classList.add('swap');
2586
+ if (st.tapOnly) {
2587
+ // Només es mostra el toc (la pestanya obriria la pantalla remota).
2588
+ const c = tvisible(st.tapOnly);
2589
+ if (c) { spot.classList.add('fade'); if (!await tourTap(c, run)) return; dot.classList.remove('on'); spot.classList.remove('fade'); }
2590
+ } else if (st.route && state.route.name !== st.route) {
2591
+ const via = tourVia(st.route);
2592
+ spot.classList.add('fade');
2593
+ if (via && !await tourTap(via, run)) return;
2594
+ // Si és dins de «Més», s'obre el menú un instant com faria el dit.
2595
+ if (via?.dataset?.act === 'more') await tpause(380);
2596
+ if (run !== tourRun) return;
2597
+ go(HASH[st.route]);
2598
+ window.scrollTo({ top: 0, behavior: 'instant' });
2599
+ await twait(() => state.route.name === st.route, 1500);
2600
+ dot.classList.remove('on');
2601
+ if (run !== tourRun) return;
2602
+ }
2603
+ const find = () => (st.center ? null : tvisible(tval(mq.matches && 'm' in st ? st.m : st.target)));
2604
+ if (!st.center) await twait(find, 3200);
2605
+ if (run !== tourRun) return;
2606
+ T.busy = false; T.drawn = '';
2607
+ tourDraw(true);
2608
+ spot.classList.remove('fade');
2609
+ card.classList.remove('swap');
2610
+ if (run === tourRun) card.querySelector('[data-tour=next]')?.focus({ preventScroll: true });
2356
2611
  }
2357
2612
  function tourDraw(scroll) {
2358
- if (!T.on) return;
2613
+ if (!T.on || T.busy) return;
2359
2614
  const st = TSTEPS[T.i], card = $('#tourCard'), busy = $('#sheet').open || $('#palette').open;
2360
2615
  $('#tour').hidden = busy; if (busy) return;
2361
- const n = TSTEPS.length - 2;
2362
- card.className = 'tour-card' + (st.center ? ' center' : '');
2363
- card.innerHTML = `<div class="tour-head">${bot(st.mood || 'idle')}${st.center ? '' : `<span class="tour-step">Pas ${T.i} de ${n}</span>`}<button class="tour-x" data-tour="close" type="button" aria-label="Tanca el tutorial">×</button></div>
2616
+ const key = `${T.i}|${mq.matches}`;
2617
+ if (T.drawn !== key) {
2618
+ T.drawn = key;
2619
+ const n = TSTEPS.length - 2, total = TSTEPS.slice(1, -1).filter((x) => !tskip(x)), pos = total.indexOf(st) + 1;
2620
+ card.className = 'tour-card' + (st.center ? ' center' : '');
2621
+ card.innerHTML = `<div class="tour-head">${bot(st.mood || 'idle')}${st.center ? '' : `<span class="tour-step">Pas ${pos} de ${total.length}</span>`}<button class="tour-x" data-tour="close" type="button" aria-label="Tanca el tutorial">×</button></div>
2364
2622
  <h3 id="tourT">${tval(st.title)}</h3><p>${tval(st.text)}</p>
2623
+ ${st.center ? '' : `<div class="tour-dots" aria-hidden="true">${total.map((_, k) => `<i class="${k + 1 === pos ? 'on' : k + 1 < pos ? 'done' : ''}"></i>`).join('')}</div>`}
2365
2624
  <div class="tour-foot">${st.center ? '' : `<div>${T.i > 1 ? '<button class="btn sm ghost" data-tour="back" type="button">Enrere</button>' : ''}</div>`}
2366
2625
  <div class="row">${T.i === 0 ? '<button class="btn sm ghost" data-tour="close" type="button">Ara no</button>' : ''}<button class="btn sm ink" data-tour="next" type="button">${tval(st.next) || 'Següent'}</button></div></div>`;
2367
- window.NioBot?.refresh(); tourPlace(scroll);
2626
+ window.NioBot?.refresh();
2627
+ }
2628
+ tourPlace(scroll);
2368
2629
  }
2369
2630
  function tourTarget() {
2370
- const st = TSTEPS[T.i], sel = st.center ? null : tval(mq.matches && 'm' in st ? st.m : st.target), e = sel && document.querySelector(sel);
2371
- if (!e || !e.getClientRects().length || e.closest('[inert]')) return null;
2372
- return e;
2631
+ const st = TSTEPS[T.i], sel = st.center ? null : tval(mq.matches && 'm' in st ? st.m : st.target);
2632
+ return tvisible(sel);
2373
2633
  }
2374
2634
  function tourPlace(scroll) {
2375
2635
  if (!T.on || $('#tour').hidden) return;
@@ -2408,18 +2668,18 @@ function tourPlace(scroll) {
2408
2668
  if (r.width < 260 && r.right + gap + cw < innerWidth - 12 && (top < 12 || r.bottom + ch > innerHeight)) { left = r.right + gap; top = r.top + r.height / 2 - ch / 2; }
2409
2669
  Object.assign(card.style, { left: Math.min(Math.max(12, left), innerWidth - cw - 12) + 'px', top: Math.min(Math.max(12, top), innerHeight - ch - 12) + 'px', bottom: '' });
2410
2670
  }
2411
- function tourUpdate() { if (T.on) setTimeout(() => tourDraw(false), 30); }
2671
+ function tourUpdate() { if (T.on && !T.busy) setTimeout(() => tourDraw(false), 30); }
2412
2672
  $('#tour').addEventListener('click', (e) => {
2413
2673
  const b = e.target.closest('[data-tour]'); if (!b) return;
2414
2674
  if (b.dataset.tour === 'close') tourEnd(false);
2415
2675
  else if (b.dataset.tour === 'next') tourGo(T.i + 1);
2416
- else if (b.dataset.tour === 'back') { let i = T.i - 1; while (i > 0 && TSTEPS[i].skipIf?.()) i--; tourGo(i); }
2676
+ else if (b.dataset.tour === 'back') { let i = T.i - 1; while (i > 0 && tskip(TSTEPS[i])) i--; tourGo(i); }
2417
2677
  });
2418
- addEventListener('resize', () => tourPlace(false));
2419
- addEventListener('scroll', () => tourPlace(false), { passive: true });
2678
+ addEventListener('resize', () => { if (T.on && !T.busy) tourPlace(false); });
2679
+ addEventListener('scroll', () => { if (T.on && !T.busy) tourPlace(false); }, { passive: true });
2420
2680
  addEventListener('keydown', (e) => {
2421
2681
  if (!T.on || $('#sheet').open || $('#palette').open || e.target.closest?.('input, textarea')) return;
2422
- if (e.key === 'Escape') tourEnd(false); else if (e.key === 'ArrowRight') tourGo(T.i + 1); else if (e.key === 'ArrowLeft' && T.i > 1) tourGo(T.i - 1);
2682
+ if (e.key === 'Escape') tourEnd(false); else if (e.key === 'ArrowRight') tourGo(T.i + 1); else if (e.key === 'ArrowLeft' && T.i > 1) { let i = T.i - 1; while (i > 0 && tskip(TSTEPS[i])) i--; tourGo(i); }
2423
2683
  });
2424
2684
  $('#sheet').addEventListener('close', () => setTimeout(() => tourDraw(false), 30));
2425
2685
  $('#palette').addEventListener('close', () => setTimeout(() => tourDraw(false), 30));