nio-cat 0.4.3 → 0.5.0

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
@@ -19,6 +19,8 @@ const ICON = {
19
19
  phone: '<rect x="6" y="3" width="12" height="18" rx="3"/><path d="M10.5 17.5h3"/>',
20
20
  machine: '<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/>',
21
21
  board: '<path d="M4 20l1.2-4.4L16 4.8a2 2 0 0 1 2.9 0l.3.3a2 2 0 0 1 0 2.9L8.4 18.8z"/>',
22
+ settings: '<path d="M5 7h9M18 7h1M5 17h1M10 17h9"/><circle cx="16" cy="7" r="2"/><circle cx="8" cy="17" r="2"/>',
23
+ help: '<circle cx="12" cy="12" r="9"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.6M12 17.2v.1"/>',
22
24
  models: '<path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z"/><path d="M4 7.5l8 4.5 8-4.5M12 12v9"/>',
23
25
  trash: '<path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/>',
24
26
  download: '<path d="M12 4v11M7 10l5 5 5-5M5 20h14"/>',
@@ -29,10 +31,13 @@ const ICON = {
29
31
  refresh: '<path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/>', back: '<path d="M15 5l-7 7 7 7"/>',
30
32
  bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
31
33
  server: '<rect x="3" y="4" width="18" height="6" rx="2"/><rect x="3" y="14" width="18" height="6" rx="2"/><path d="M7 7h.01M7 17h.01"/>',
34
+ close: '<path d="M6 6l12 12M18 6L6 18"/>',
32
35
  scan: '<path d="M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3M4 12h16"/>',
33
36
  check: '<path class="tick" d="M5 12.5l4.5 4.5L19 7.5"/>',
37
+ mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/>', speaker: '<path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5zM15.5 9a4 4 0 0 1 0 6M18 6.5a8 8 0 0 1 0 11"/>',
34
38
  copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/>'
35
39
  };
40
+ const agentBot = (agent, mood = 'idle') => `<span class="abot" data-agent="${esc(agent)}">${bot(mood)}</span>`;
36
41
  const AGENT_NAMES = { claude: 'Claude Code', codex: 'Codex', grok: 'Grok', antigravity: 'Antigravity', ollama: 'Ollama', demo: 'Demo', browser: 'Xat local' };
37
42
  const MODES = {
38
43
  llegir: { label: 'Només llegir', short: 'Llegir' },
@@ -57,9 +62,26 @@ const machine = () => store.machines[store.active] || store.machines[0] || null;
57
62
  /* ---------------------------------------------------------------- utilitats */
58
63
  function toast(msg, ms = 2800) {
59
64
  const box = $('#toasts'), t = document.createElement('div');
60
- t.className = 'toast'; t.textContent = msg;
61
- while (box.children.length >= 2) box.firstElementChild.remove();
62
- box.appendChild(t); setTimeout(() => t.remove(), ms);
65
+ t.className = 'toast'; t.setAttribute('role', 'status'); t.textContent = msg;
66
+ while (box.children.length >= 3) box.firstElementChild.remove();
67
+ box.appendChild(t);
68
+ let x0 = null, dx = 0, timer = setTimeout(() => dismiss(0), ms);
69
+ const dismiss = (dir) => {
70
+ clearTimeout(timer); if (!t.isConnected) return;
71
+ if (!dir) { t.classList.add('out'); setTimeout(gone, 200); return; }
72
+ t.style.transition = 'transform .18s var(--ease), opacity .18s'; t.style.transform = `translateX(${dir * 120}%)`; t.style.opacity = '0'; setTimeout(gone, 190);
73
+ };
74
+ const gone = () => { t.remove(); if (!box.children.length) try { box.hidePopover(); } catch {} };
75
+ t.addEventListener('pointerdown', (e) => { x0 = e.clientX; dx = 0; clearTimeout(timer); t.setPointerCapture(e.pointerId); t.style.transition = 'none'; });
76
+ t.addEventListener('pointermove', (e) => { if (x0 === null) return; dx = e.clientX - x0; t.style.transform = `translateX(${dx}px)`; t.style.opacity = String(Math.max(0.2, 1 - Math.abs(dx) / 220)); });
77
+ const end = () => { if (x0 === null) return; x0 = null; if (Math.abs(dx) > 60) return dismiss(Math.sign(dx)); t.style.transition = 'transform .18s var(--ease), opacity .18s'; t.style.transform = ''; t.style.opacity = ''; timer = setTimeout(() => dismiss(0), 2000); };
78
+ t.addEventListener('pointerup', end); t.addEventListener('pointercancel', end);
79
+ raiseToasts();
80
+ }
81
+ // Les alertes viuen a la capa superior (popover) perquè quedin per sobre de les fulles i no es vegin borroses.
82
+ function raiseToasts() {
83
+ const box = $('#toasts'); if (!box?.children.length) return;
84
+ try { box.hidePopover(); } catch {} try { box.showPopover(); } catch {}
63
85
  }
64
86
  function ago(ts) {
65
87
  const s = Math.round((Date.now() - ts) / 1000);
@@ -123,7 +145,7 @@ async function connect() {
123
145
  const ctrl = new AbortController(); streamCtrl = ctrl;
124
146
  try {
125
147
  state.info = await api('/api/info');
126
- const res = await fetch(`${m.url}/api/stream`, { headers: { Authorization: `Bearer ${m.token}` }, signal: ctrl.signal, cache: 'no-store' });
148
+ const res = await fetch(`${m.url}/api/stream?d=${encodeURIComponent(deviceId())}&k=${isPhone() ? 'phone' : 'desktop'}`, { headers: { Authorization: `Bearer ${m.token}` }, signal: ctrl.signal, cache: 'no-store' });
127
149
  if (res.status === 401) throw Object.assign(new Error('La clau no és vàlida. Torna a connectar amb el codi QR.'), { fatal: true });
128
150
  if (!res.ok || !res.body) throw new Error(`Error ${res.status}`);
129
151
  state.connected = true; state.lastError = null; retry = 0;
@@ -145,13 +167,37 @@ async function connect() {
145
167
  state.lastError = e.message === 'Failed to fetch' || e.name === 'TypeError' ? (location.protocol === 'https:' && /^http:\/\/(127\.0\.0\.1|localhost)/.test(m.url) ? 'El navegador no deixa que aquesta web parli amb aquest ordinador. Prova Chrome, o engega nio amb --local.' : 'No arribo a l’ordinador') : e.message;
146
168
  render();
147
169
  if (e.fatal) return;
170
+ if (m.kind === 'vps' && m.via && retry >= 2) autoReconnect(m);
148
171
  setTimeout(() => machine() && !ctrl.signal.aborted && connect(), Math.min(15000, 1000 * 2 ** retry++));
149
172
  }
150
173
  }
174
+ // Un servidor caigut es torna a connectar sol: un altre ordinador connectat hi entra per SSH i en desa l'adreça nova.
175
+ const reconTry = new Map();
176
+ async function autoReconnect(m) {
177
+ const t = reconTry.get(m.via) || { at: 0, n: 0, busy: false };
178
+ if (t.busy || t.n >= 3 || Date.now() - t.at < 45000) return;
179
+ t.busy = true; t.at = Date.now(); t.n++; reconTry.set(m.via, t);
180
+ try {
181
+ let helper = null;
182
+ for (const h of store.machines) {
183
+ if (h.url === m.url || h.kind === 'vps' || h.url.startsWith('demo:')) continue;
184
+ try { const r = await fetch(`${h.url}/api/info`, { headers: { Authorization: `Bearer ${h.token}` }, cache: 'no-store', signal: AbortSignal.timeout(5000) }); if (r.ok) { helper = h; break; } } catch { /* prova el següent */ }
185
+ }
186
+ if (!helper) return;
187
+ toast(`Reconnectant amb ${m.name}…`);
188
+ const r = await sshAdd(m.via, () => {}, helper);
189
+ const ms = store.machines, i = ms.findIndex((x) => x.via === m.via && x.kind === 'vps');
190
+ if (i < 0) return;
191
+ ms[i].url = r.url; ms[i].token = r.token; store.machines = ms;
192
+ reconTry.delete(m.via); retry = 0;
193
+ if (store.active === i) connect();
194
+ toast(`${ms[i].name} torna a ser aquí`);
195
+ } catch (e) { toast(`No he pogut reconnectar: ${e.message}`); } finally { t.busy = false; }
196
+ }
151
197
  // Posa nom i identificador a l'ordinador actiu i fusiona els duplicats (el mateix ordinador desat amb dues adreces: wifi i 127.0.0.1).
152
198
  function renameMachine(host, id) {
153
199
  let ms = store.machines; const cur = ms[store.active]; if (!cur) return;
154
- if (host) cur.name = host;
200
+ if (host && !cur.renamed) cur.name = host;
155
201
  if (id) {
156
202
  cur.id = id;
157
203
  const keep = cur.url, list = ms.filter((m) => m === cur || m.id !== id);
@@ -163,6 +209,9 @@ function renameMachine(host, id) {
163
209
  function handle(msg) {
164
210
  const r = state.route;
165
211
  switch (msg.type) {
212
+ case 'device':
213
+ if (!isPhone() && msg.device !== deviceId()) { if ($('#qrBox')) closeSheet(); toast('Mòbil connectat correctament', 2000); }
214
+ break;
166
215
  case 'hello':
167
216
  state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
168
217
  state.approvals = new Map(msg.approvals.map((a) => [a.id, a]));
@@ -264,9 +313,11 @@ const VIEWS = {
264
313
  ordinadors: { label: 'Ordinadors', icon: ICON.machine, group: 'Ordinadors' },
265
314
  models: { label: 'Models', icon: ICON.models, group: 'Ordinadors' },
266
315
  mobil: { label: 'Xat local', icon: ICON.phone, group: 'Eines' },
267
- pissarra: { label: 'Pissarra', icon: ICON.board, group: 'Eines' }
316
+ pissarra: { label: 'Pissarra', icon: ICON.board, group: 'Eines' },
317
+ ajustos: { label: 'Ajustos', icon: ICON.settings, group: 'Compte' },
318
+ ajuda: { label: 'Ajuda', icon: ICON.help, group: 'Compte' }
268
319
  };
269
- const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', ordinadors: '#/ordinadors', models: '#/models', mobil: '#/xat-local', pissarra: '#/pissarra' };
320
+ const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', ordinadors: '#/ordinadors', models: '#/models', mobil: '#/xat-local', pissarra: '#/pissarra', ajustos: '#/ajustos', ajuda: '#/ajuda' };
270
321
  const NEEDS_MACHINE = new Set(['sessions', 'approvals', 'models', 'session']);
271
322
  function parseRoute() {
272
323
  const h = location.hash.replace(/^#\/?/, '');
@@ -298,7 +349,7 @@ function renderChrome() {
298
349
  const m = machine(), sc = $('#sideConn');
299
350
  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)}`;
300
351
  sc.setAttribute('role', 'button'); sc.tabIndex = 0; sc.classList.add('switch'); sc.dataset.act = 'machines'; sc.title = 'Ordinadors';
301
- const more = ['models', 'mobil', 'pissarra'].includes(r.name);
352
+ const more = ['mobil', 'pissarra', 'ajustos', 'ajuda'].includes(r.name);
302
353
  $('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['approvals', ICON.approvals, 'Aprovacions', active === 'approvals'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
303
354
  .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('')
304
355
  + `<button type="button" data-act="more" ${more ? 'aria-current="page"' : ''}>${svg(ICON.more)}<span>Més</span></button>`;
@@ -324,7 +375,7 @@ function render() {
324
375
  else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
325
376
  else {
326
377
  const html = r.name === 'mobil' ? viewLocal() : null;
327
- view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html : r.name === 'ordinadors' ? viewComputers() : r.name === 'sessions' ? viewSessions() : r.name === 'approvals' ? viewApprovals() : viewModels()}</div>`;
378
+ 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 === 'ajustos' ? viewSettings() : r.name === 'ajuda' ? viewHelp() : viewModels()}</div>`;
328
379
  if (r.name === 'mobil') bindLocal();
329
380
  if (r.name === 'models' && machine()) loadOllama();
330
381
  }
@@ -334,6 +385,7 @@ function render() {
334
385
  }
335
386
 
336
387
  /* ---------- Ordinadors (llista, afegir i detall de l'actiu) */
388
+ const deviceId = () => { let d = LS.get('nio.device', ''); if (!d) { d = Math.random().toString(36).slice(2, 12); LS.set('nio.device', d); } return d; };
337
389
  const isPhone = () => isTouch || matchMedia('(max-width: 820px)').matches;
338
390
  const cmdBox = (c) => `<div class="cmd big"><code>${esc(c)}</code><button class="btn sm" data-copy="${esc(c)}" type="button">${svg(ICON.copy)}Copia</button></div>`;
339
391
  function computerCard(m, i) {
@@ -341,7 +393,7 @@ function computerCard(m, i) {
341
393
  const st = on ? (state.connected ? '<span class="chip ok"><i class="dot on"></i>Connectat</span>' : `<span class="chip">${esc(state.lastError ? 'Sense connexió' : 'Connectant…')}</span>`) : '';
342
394
  return `<div class="card computer ${on ? 'active' : ''}">
343
395
  <div class="row" style="flex-wrap:nowrap;gap:12px;align-items:flex-start"><span class="c-ico">${svg(machineIcon(m))}</span><div class="grow"><div class="row between"><b>${esc(m.name)}</b>${st}</div><div class="muted small">${esc(machineSub(m))}</div></div></div>
344
- <div class="row c-actions">${on ? `<button class="btn ink sm" data-act="open-sessions" type="button">Sessions</button>${isPhone() ? '' : `<button class="btn sm" data-act="mobile-qr" type="button">Continua al mòbil</button>`}` : `<button class="btn ink sm" data-machine="${i}" type="button">Obre</button>`}${m.kind === 'vps' && m.via && (!on || !state.connected) ? `<button class="btn sm" data-reconnect="${i}" type="button">Torna a connectar</button>` : ''}<button class="btn ghost sm" data-forget="${i}" type="button">Oblida</button></div>
396
+ <div class="row c-actions">${on ? `<button class="btn ink sm" data-act="open-sessions" type="button">Sessions</button>${isPhone() ? '' : `<button class="btn sm" data-act="mobile-qr" type="button">Continua al mòbil</button>`}` : `<button class="btn ink sm" data-machine="${i}" type="button">Obre</button>`}${m.kind === 'vps' && m.via && (!on || !state.connected) ? `<button class="btn sm" data-reconnect="${i}" type="button">Torna a connectar</button>` : ''}<button class="btn ghost sm" data-rename="${i}" type="button">Canvia el nom</button><button class="btn ghost sm" data-forget="${i}" type="button">Oblida</button></div>
345
397
  </div>`;
346
398
  }
347
399
  // Ajuda sempre a mà: com es fa cada cosa, i què mirar si no connecta.
@@ -354,9 +406,16 @@ function helpPanel(open) {
354
406
  ${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>`)}
355
407
  ${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>`)}
356
408
  ${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>`)}
357
- <div class="row"><button class="btn sm" type="button" data-act="tour">Fes el tutorial</button></div>
358
409
  </div></section>`;
359
410
  }
411
+ function viewSettings() {
412
+ const head = '<div class="top"><div><h1>Ajustos</h1><p>Avisos al mòbil i veu.</p></div></div>';
413
+ const avisos = machine() ? `<section class="panel"><header><h2>Avisos al mòbil</h2></header><div class="body stack"><p class="small">Rep un avís quan un agent et demana permís o acaba la feina.</p>${isIOS && !standalone ? '<p class="muted small">A l’iPhone, primer afegeix nio a la pantalla d’inici (Comparteix → Afegeix a la pantalla d’inici) i obre’l des d’allà.</p>' : ''}<div class="row"><button class="btn ink grow" data-act="push" type="button">Activa els avisos</button><button class="btn" data-act="push-test" type="button">Envia’n un de prova</button></div></div></section>` : '';
414
+ return `${head}<div class="stack-lg" style="max-width:640px">${avisos}${voicePanel()}</div>`;
415
+ }
416
+ function viewHelp() {
417
+ return `<div class="top"><div><h1>Ajuda</h1><p>Com es fa cada cosa i què mirar si no connecta.</p></div><button class="btn ink" type="button" data-act="tour">Fes el tutorial</button></div><div style="max-width:720px">${helpPanel()}</div>`;
418
+ }
360
419
  function viewComputers() {
361
420
  const ms = store.machines, m = machine(), info = state.info;
362
421
  const head = `<div class="top"><div><h1>Ordinadors</h1><p>On treballen els agents: el teu ordinador o un servidor. Tria’n un i dona-li feina.</p></div><button class="btn ink" id="addComputer" data-act="add-computer" type="button">${svg(ICON.plus)}Afegeix ordinador</button></div>`;
@@ -364,19 +423,16 @@ function viewComputers() {
364
423
  const steps = isPhone()
365
424
  ? [['Engega nio a l’ordinador', 'Al terminal: <span class="mono">npx nio-cat</span>.'], ['Escaneja el QR', 'O escriu el codi <span class="mono">nio-…</span> que surt al terminal.'], ['Dona feina a un agent', 'Crea una sessió i segueix-la en directe.']]
366
425
  : [['Engega nio', 'Al terminal: <span class="mono">npx nio-cat</span>. S’obre aquesta web ja connectada.'], ['O afegeix un servidor', 'Amb SSH: <span class="mono">usuari@servidor</span>. nio hi fa la resta.'], ['Continua al mòbil', 'Amb un QR o un codi, quan vulguis.']];
367
- return `${head}<div class="panel empty">${bot('idle')}<h2>Connecta el teu primer ordinador</h2><ol class="stepper">${steps.map(([t, d], i) => `<li><span class="n">${i + 1}</span><div><b>${t}</b><p>${d}</p></div></li>`).join('')}</ol><button class="btn ink" data-act="add-computer" type="button">${svg(ICON.plus)}Afegeix ordinador</button></div>${helpPanel()}`;
426
+ return `${head}<div class="panel empty">${bot('idle')}<h2>Connecta el teu primer ordinador</h2><ol class="stepper">${steps.map(([t, d], i) => `<li><span class="n">${i + 1}</span><div><b>${t}</b><p>${d}</p></div></li>`).join('')}</ol><button class="btn ink" data-act="add-computer" type="button">${svg(ICON.plus)}Afegeix ordinador</button></div>`;
368
427
  }
369
428
  const detail = m && state.connected ? `<h2 class="sec">${esc(m.name)}</h2>
370
- <div class="grid2">
429
+ <div class="grid2 one">
371
430
  <div class="stack">
372
431
  <section class="panel"><header><h2>Agents</h2><button class="btn sm ghost" data-act="refresh-agents" type="button">${svg(ICON.refresh)}Torna a detectar</button></header><div class="body">${info ? info.agents.map((a) => `<div class="agent-row"><i class="dot ${a.available ? 'on' : ''}"></i><div class="grow"><div class="row"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="muted small mono">${esc(a.available ? a.version || 'Disponible' : a.login)}</div></div></div>`).join('') : '<p class="muted">Carregant…</p>'}</div></section>
373
432
  ${info ? `<section class="panel"><header><h2>Sistema</h2></header><div class="body"><dl class="kv"><dt>Sistema</dt><dd>${esc(info.platform)} · ${esc(info.user)}</dd><dt>Versió de nio</dt><dd>${esc(info.version)}</dd></dl></div></section>` : ''}
374
433
  </div>
375
- <div class="stack">
376
- <section class="panel"><header><h2>Avisos al mòbil</h2></header><div class="body stack"><p class="small">Rep un avís quan un agent et demana permís o acaba la feina.</p>${isIOS && !standalone ? '<p class="muted small">A l’iPhone, primer afegeix nio a la pantalla d’inici (Comparteix → Afegeix a la pantalla d’inici) i obre’l des d’allà.</p>' : ''}<div class="row"><button class="btn ink grow" data-act="push" type="button">Activa els avisos</button><button class="btn" data-act="push-test" type="button">Envia’n un de prova</button></div></div></section>
377
- </div>
378
434
  </div>` : '';
379
- return `${head}<div class="computers">${ms.map(computerCard).join('')}</div>${detail}${helpPanel()}`;
435
+ return `${head}<div class="computers">${ms.map(computerCard).join('')}</div>${detail}`;
380
436
  }
381
437
 
382
438
  /* ---------- Afegir un ordinador: aquest, un servidor per SSH o un codi */
@@ -388,8 +444,12 @@ async function probeLocal() {
388
444
  } catch { return null; }
389
445
  }
390
446
  // Afegeix un servidor per SSH des de l'ordinador actiu. El progrés arriba línia a línia.
391
- async function sshAdd(target, onStep) {
392
- const m = machine(); if (!m) throw new Error('Primer connecta un ordinador');
447
+ function sshHelper() {
448
+ const a = machine(), ok = (x) => x && x.kind !== 'vps' && !x.url.startsWith('demo:');
449
+ return ok(a) ? a : store.machines.find(ok) || null;
450
+ }
451
+ async function sshAdd(target, onStep, via) {
452
+ const m = via || sshHelper(); if (!m) throw new Error('Primer connecta un ordinador');
393
453
  const res = await fetch(`${m.url}/api/ssh/add`, { method: 'POST', headers: { Authorization: `Bearer ${m.token}`, 'content-type': 'application/json' }, body: JSON.stringify({ target }) });
394
454
  if (!res.ok || !res.body) throw new Error((await res.json().catch(() => ({}))).error || 'Aquest ordinador no pot afegir servidors. Actualitza nio.');
395
455
  const reader = res.body.getReader(), dec = new TextDecoder(); let buf = '', last = null;
@@ -398,7 +458,7 @@ async function sshAdd(target, onStep) {
398
458
  buf += dec.decode(value, { stream: true }); let i;
399
459
  while ((i = buf.indexOf('\n')) >= 0) {
400
460
  const o = JSON.parse(buf.slice(0, i)); buf = buf.slice(i + 1);
401
- if (o.step) onStep(o.step); else last = o;
461
+ if (o.step) onStep(o.step); else if (!o.ping) last = o;
402
462
  }
403
463
  }
404
464
  if (!last || last.error) throw new Error(last?.error || 'S’ha tallat la connexió');
@@ -413,56 +473,59 @@ async function startScan(video, onText) {
413
473
  }
414
474
  function addComputerSheet(tab, opts = {}) {
415
475
  const phone = isPhone(), canScan = 'BarcodeDetector' in window && !!navigator.mediaDevices?.getUserMedia;
416
- const TABS = phone ? [['code', 'Codi o QR'], ['vps', 'Servidor (SSH)']] : [['local', 'Aquest ordinador'], ['vps', 'Servidor (SSH)'], ['code', 'Tinc un codi']];
476
+ const TABS = phone ? [['code', 'Codi o QR', ICON.scan], ['vps', 'Servidor', ICON.server]] : [['local', 'Aquest ordinador', ICON.machine], ['vps', 'Servidor', ICON.server], ['code', 'Tinc un codi', ICON.scan]];
417
477
  let cur = TABS.some((t) => t[0] === tab) ? tab : TABS[0][0], poll = 0, scan = null, shown = '', hosts = [];
478
+ const intro = (t, d) => `<div class="add-intro"><h3>${t}</h3>${d ? `<p>${d}</p>` : ''}</div>`;
479
+ const todo = (items) => `<ol class="todo">${items.map((x, i) => `<li><span class="n">${i + 1}</span><div>${x}</div></li>`).join('')}</ol>`;
480
+ const live = (t) => `<p class="live"><i class="pulse"></i>${t}</p>`;
418
481
  openSheet('', (sh) => {
419
- sh.innerHTML = `<div class="wiz-head"><div class="grab"></div><div class="row between" style="flex-wrap:nowrap"><h2>Afegeix un ordinador</h2><button class="icon-btn" type="button" data-close aria-label="Tanca">×</button></div><div class="seg" id="addTabs" role="group">${TABS.map(([k, l]) => `<button type="button" data-tab="${k}">${l}</button>`).join('')}</div></div><div class="wiz-body" id="addBody"></div>`;
482
+ sh.innerHTML = `<div class="wiz-head"><div class="grab"></div><div class="row between" style="flex-wrap:nowrap"><h2>Afegeix un ordinador</h2><button class="icon-btn" type="button" data-close aria-label="Tanca">${svg(ICON.close)}</button></div><div class="seg" id="addTabs" role="group" aria-label="Com vols connectar-lo">${TABS.map(([k, l, ic]) => `<button type="button" data-tab="${k}">${svg(ic)}<span>${l}</span></button>`).join('')}</div></div><div class="wiz-body" id="addBody"></div>`;
420
483
  const body = $('#addBody', sh);
421
484
  const stop = () => { clearTimeout(poll); scan?.stop(); scan = null; shown = ''; };
422
485
  const set = (key, html) => { if (key === shown) return false; shown = key; body.innerHTML = html; return true; };
423
486
  const draw = () => {
424
- stop(); if (cur === 'vps' && !hosts.length && machine()) api('/api/ssh/hosts').then((h) => { hosts = h; if (cur === 'vps' && shown === 'vps') { shown = ''; vps(); } }).catch(() => {});
487
+ stop(); if (cur === 'vps' && !hosts.length && machine()) api('/api/ssh/hosts').then((h) => { hosts = h; if (cur === 'vps' && shown === 'vps' && !$('#sshSteps', sh)?.children.length) { shown = ''; vps(); } }).catch(() => {});
425
488
  $$('#addTabs button', sh).forEach((b) => b.setAttribute('aria-pressed', b.dataset.tab === cur));
426
489
  body.scrollTop = 0;
427
490
  if (cur === 'local') local(); else if (cur === 'vps') vps(); else code();
428
491
  };
429
492
  const local = async () => {
430
- set('wait', '<p class="muted small">Mirant si nio funciona en aquest ordinador…</p>');
493
+ set('wait', live('Mirant si nio funciona en aquest ordinador…'));
431
494
  const d = await probeLocal(); if (cur !== 'local') return;
432
495
  const saved = d && store.machines.findIndex((x) => x.id === d.id);
433
- if (d && saved >= 0) set('saved', `<p class="wiz-q">Aquest ordinador ja és a la llista</p><p class="muted small">${esc(d.name)}</p><div class="row"><button class="btn ink" type="button" data-open-saved="${saved}">Obre</button></div>`);
434
- else if (d) set('found', `<p class="wiz-q">nio funciona en aquest ordinador</p><p class="muted small">Aquest navegador encara no hi té accés. Torna a engegar nio perquè obri el panell ja connectat:</p>${cmdBox('npx nio-cat')}`);
435
- else set('none', `<p class="wiz-q">Engega nio en aquest ordinador</p><p class="muted small">Al terminal, des de la carpeta dels teus projectes:</p>${cmdBox('npx nio-cat')}<p class="muted small">S’obrirà aquest panell ja connectat. Cal Node.js 18 o més nou.</p>`);
496
+ if (d && saved >= 0) set('saved', `${intro('Aquest ordinador ja és a la llista', 'El tens connectat com a «' + esc(d.name) + '».')}<div class="row"><button class="btn ink" type="button" data-open-saved="${saved}">Obre sessions</button></div>`);
497
+ else if (d) set('found', `${intro('nio funciona en aquest ordinador', 'Aquest navegador encara no hi té accés. Torna a engegar nio i obrirà el panell ja connectat.')}${cmdBox('npx nio-cat')}`);
498
+ else set('none', `${intro('Engega nio en aquest ordinador')}${todo(['Obre un terminal a la carpeta dels teus projectes.', `Executa:${cmdBox('npx nio-cat')}`, 'S’obrirà aquest panell ja connectat.'])}${live('Esperant que nio s’engegui…')}<p class="hint">Cal Node.js 18 o més nou.</p>`);
436
499
  if (saved < 0 || !d) poll = setTimeout(local, 3000);
437
500
  };
438
501
  const runSsh = async (target) => {
439
- const steps = $('#sshSteps', sh), btn = $('#sshForm button', sh); if (!steps) return; btn.disabled = true; steps.innerHTML = '';
502
+ const steps = $('#sshSteps', sh), btn = $('#sshForm button', sh); if (!steps || !target) return; btn.disabled = true; steps.innerHTML = ''; shown = 'run';
440
503
  const add = (t) => { $$('li.on', steps).forEach((l) => { l.className = 'ok'; }); steps.insertAdjacentHTML('beforeend', `<li class="on">${esc(t)}…</li>`); };
441
504
  try {
442
- const r = await sshAdd(target, add);
505
+ add(`Connectant amb ${target}`);
506
+ const r = await sshAdd(target, add, sshHelper());
443
507
  LS.set('nio.lastSsh', target);
444
508
  const old = store.machines.findIndex((x) => x.via === target && x.kind === 'vps');
445
- if (old >= 0) { const ms = store.machines; ms.splice(old, 1); store.machines = ms; store.active = Math.min(store.active, Math.max(0, ms.length - 1)); }
446
- addMachine({ url: r.url, token: r.token, name: r.host, kind: 'vps', via: target });
509
+ let keepName = null; if (old >= 0) { const ms = store.machines; if (ms[old].renamed) keepName = ms[old].name; ms.splice(old, 1); store.machines = ms; store.active = Math.min(store.active, Math.max(0, ms.length - 1)); }
510
+ addMachine({ url: r.url, token: r.token, name: keepName || r.host, kind: 'vps', via: target });
511
+ if (keepName) { const ms = store.machines; ms[store.active].renamed = true; store.machines = ms; }
447
512
  shown = 'vps-ok';
448
- body.innerHTML = `<p class="wiz-q">${esc(r.host)} és a punt</p>${r.claude ? '<p class="muted small">Ja el pots fer servir: crea-hi una sessió amb Claude Code.</p>' : '<p class="muted small">Claude Code no hi és. Entra al servidor, instal·la’l i inicia sessió amb <span class="mono">claude</span>.</p>'}<div class="row"><a class="btn ink" href="#/sessions" data-close>Obre sessions</a><button class="btn" type="button" data-act="mobile-qr">Continua al mòbil</button></div>`;
449
- } catch (err) { $$('li.on', steps).forEach((l) => { l.className = 'err'; }); steps.insertAdjacentHTML('beforeend', `<li class="err">${esc(err.message)}</li>`); btn.disabled = false; }
513
+ body.innerHTML = `${intro(`${esc(r.host)} és a punt`, r.claude ? 'Ja el pots fer servir: crea-hi una sessió amb Claude Code.' : 'Claude Code no hi és. Entra al servidor, instal·la’l i inicia sessió amb <span class="mono">claude</span>.')}<div class="row"><a class="btn ink" href="#/sessions" data-close>Obre sessions</a><button class="btn" type="button" data-act="mobile-qr">Continua al mòbil</button></div>`;
514
+ } catch (err) { $$('li.on', steps).forEach((l) => { l.className = 'err'; }); steps.insertAdjacentHTML('beforeend', `<li class="err">${esc(err.message)}</li>`); btn.disabled = false; shown = 'vps'; }
450
515
  };
451
516
  const vps = () => {
452
- const m = machine();
453
- if (!m) return set('vps-no', `<p class="wiz-q">Primer, un ordinador</p><p class="muted small">El servidor s’afegeix des d’un ordinador que ja tinguis connectat, perquè és ell qui hi entra per SSH.</p>${phone ? '' : '<button class="btn" type="button" data-tab="local">Afegeix aquest ordinador</button>'}`);
454
- set('vps', `<p class="wiz-q">Un servidor amb Claude Code</p>
455
- <p class="muted small">${esc(m.name)} hi entra per SSH, hi engega nio amb un túnel segur i el deixa llest per al mòbil.</p>
456
- ${hosts.length ? `<div class="row">${hosts.map((h) => `<button class="chip" type="button" data-host="${esc(h.target)}" title="${esc(h.host)}">${esc(h.target)}</button>`).join('')}</div>` : ''}
457
- <form class="pair-form" id="sshForm"><input type="text" id="sshIn" value="${esc(opts.target || LS.get('nio.lastSsh', ''))}" placeholder="usuari@servidor" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Usuari i servidor"><button class="btn ink" type="submit">Afegeix</button></form>
517
+ const m = sshHelper();
518
+ if (!m) return set('vps-no', `${intro('Primer, un ordinador', 'El servidor s’afegeix des d’un ordinador (no des d’un altre servidor), perquè és ell qui hi entra per SSH.')}${phone ? '<p class="hint">Des del mòbil, connecta primer l’ordinador amb el codi o el QR.</p>' : '<div class="row"><button class="btn" type="button" data-tab="local">Afegeix aquest ordinador</button></div>'}`);
519
+ set('vps', `${intro('Un servidor amb Claude Code', `${esc(m.name)} hi entra per SSH, hi engega nio amb un túnel segur i el deixa llest per al mòbil.`)}
520
+ <form class="field-row" id="sshForm"><input type="text" id="sshIn" value="${esc(opts.target || LS.get('nio.lastSsh', ''))}" placeholder="usuari@servidor" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Usuari i servidor"><button class="btn ink" type="submit">Afegeix</button></form>
521
+ ${hosts.length ? `<div class="hosts"><span>Del teu <span class="mono">~/.ssh/config</span></span><div class="row">${hosts.map((h) => `<button class="chip" type="button" data-host="${esc(h.target)}" title="${esc(h.host)}">${esc(h.target)}</button>`).join('')}</div></div>` : ''}
458
522
  <ul class="steps" id="sshSteps"></ul>
459
- <p class="muted small">Cal entrar-hi sense contrasenya (clau SSH) i que el servidor tingui Node.js 18 o més nou. Claude Code hi ha d’estar instal·lat i amb sessió iniciada.</p>`);
523
+ <ul class="checks"><li>Accés per SSH sense contrasenya (clau SSH)</li><li>Node.js 18 o més nou</li><li>Claude Code instal·lat i amb sessió iniciada</li></ul>`);
460
524
  };
461
525
  const code = () => {
462
- set('code', `<p class="wiz-q">${phone ? 'Escaneja o escriu el codi' : 'Escriu el codi'}</p>
463
- <form class="pair-form" id="pairForm"><input type="text" id="pairInput" placeholder="nio-…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Codi de connexió"><button class="btn ink" type="submit">Connecta</button></form>
464
- ${canScan ? `<button class="btn" type="button" data-scan>${svg(ICON.scan)}Escaneja un QR</button><video class="scan" id="scanVideo" playsinline muted hidden></video>` : phone ? '<p class="muted small">Per escanejar, obre la càmera del mòbil i apunta al QR: s’obrirà nio.</p>' : ''}
465
- <p class="muted small">El codi surt al terminal, sota el QR, o a «Continua al mòbil».</p>`);
526
+ set('code', `${intro(phone ? 'Escaneja o escriu el codi' : 'Escriu el codi', 'Surt al terminal, sota el QR, o a «Continua al mòbil».')}
527
+ <form class="field-row" id="pairForm"><input type="text" id="pairInput" placeholder="nio-…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Codi de connexió"><button class="btn ink" type="submit">Connecta</button></form>
528
+ ${canScan ? `<div class="or"><span>o</span></div><button class="btn block" type="button" data-scan>${svg(ICON.scan)}Escaneja un QR</button><video class="scan" id="scanVideo" playsinline muted hidden></video>` : phone ? '<p class="hint">Per escanejar, obre la càmera del mòbil i apunta al QR: s’obrirà nio.</p>' : ''}`);
466
529
  };
467
530
  sh.onclick = async (e) => {
468
531
  const t = e.target.closest('[data-tab]'); if (t) { cur = t.dataset.tab; return draw(); }
@@ -482,15 +545,29 @@ function addComputerSheet(tab, opts = {}) {
482
545
  };
483
546
  draw();
484
547
  if (opts.auto && cur === 'vps') setTimeout(() => runSsh(opts.target), 50);
485
- }, 'wiz');
548
+ }, 'wiz compact');
486
549
  }
487
550
  function machinesSheet() {
488
551
  const ms = store.machines;
489
552
  openSheet(`<h2>Ordinadors</h2>${ms.length ? `<div class="stack">${ms.map((x, i) => `<button class="card row" type="button" data-machine="${i}" style="flex-wrap:nowrap"><span class="c-ico">${svg(machineIcon(x))}</span><span class="grow"><b>${esc(x.name)}</b><div class="muted small">${esc(machineSub(x))}</div></span>${i === store.active ? `<span class="chip ok"><i class="dot ${state.connected ? 'on' : ''}"></i>Actiu</span>` : ''}</button>`).join('')}</div>` : '<p class="muted small">Encara no hi ha cap ordinador.</p>'}
490
553
  <div class="row between sheet-foot">${ms.length ? '<a class="btn ghost" href="#/ordinadors" data-close>Gestiona</a>' : '<button class="btn ghost" type="button" data-close>Tanca</button>'}<button class="btn ink" type="button" data-act="add-computer">${svg(ICON.plus)}Afegeix ordinador</button></div>`, (sh) => { $$('[data-close]', sh).forEach((b) => (b.onclick = closeSheet)); });
491
554
  }
555
+ function renameSheet(i) {
556
+ const m = store.machines[i]; if (!m) return;
557
+ openSheet(`<h2>Canvia el nom</h2><p class="muted small">Només el veus tu, en aquest dispositiu. L’ordinador es continua dient «${esc(m.name)}» a la resta.</p>
558
+ <form class="field-row" id="renameForm"><input type="text" id="renameIn" value="${esc(m.name)}" maxlength="40" autocomplete="off" spellcheck="false" required aria-label="Nom de l’ordinador"><button class="btn ink" type="submit">Desa</button></form>`, (sh) => {
559
+ const inp = $('#renameIn', sh); setTimeout(() => { inp.focus(); inp.select(); }, 60);
560
+ $('#renameForm', sh).onsubmit = (e) => {
561
+ e.preventDefault(); e.stopPropagation();
562
+ const v = inp.value.trim(); if (!v) return;
563
+ const ms = store.machines; if (!ms[i]) return;
564
+ ms[i].name = v; ms[i].renamed = true; store.machines = ms; closeSheet(); render(); toast('Nom desat');
565
+ };
566
+ });
567
+ }
492
568
  function moreSheet() {
493
- openSheet(`<h2>Més</h2><div class="list">${['models', 'mobil', 'pissarra'].map((k) => `<a class="card row" href="${HASH[k]}" style="gap:12px">${svg(VIEWS[k].icon)}<b>${VIEWS[k].label}</b></a>`).join('')}<a class="card row" href="#/ordinadors" data-act="help" style="gap:12px"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.1"/></svg><b>Com es fa</b></a><button class="card row" type="button" data-act="tour" style="gap:12px"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.6M12 17.2v.1"/></svg><b>Tutorial</b></button></div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
569
+ const item = (k) => `<a class="card row" href="${HASH[k]}" style="gap:12px">${svg(VIEWS[k].icon)}<b>${VIEWS[k].label}</b></a>`;
570
+ openSheet(`<h2>Més</h2><div class="list">${['mobil', 'pissarra', 'ajustos', 'ajuda'].map(item).join('')}</div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
494
571
  }
495
572
 
496
573
  /* ---------- Sessions */
@@ -511,9 +588,9 @@ function viewSessions() {
511
588
  }
512
589
  return `${head}${list.length ? `<div class="list">${list.map((s) => `
513
590
  <a class="card session-card" href="#/s/${esc(s.id)}">
514
- <div class="row between"><span class="agent-tag" data-agent="${esc(s.agent)}">${esc(AGENT_NAMES[s.agent] || s.agent)}</span>${stateChip(s)}</div>
591
+ <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>
515
592
  <div class="title">${esc(s.title || 'Sessió nova')}</div>
516
- ${s.last ? `<div class="last">${esc(s.last)}</div>` : ''}
593
+ ${s.state === 'error' && s.error ? `<div class="last err">${esc(s.error)}</div>` : s.last ? `<div class="last">${esc(s.last)}</div>` : ''}
517
594
  <div class="meta"><span class="mono">${esc(basename(s.cwd))}</span><span>·</span><span>${esc(MODES[s.mode]?.label || '')}</span><span>·</span><span>${ago(s.updated)}</span></div>
518
595
  </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>`}`;
519
596
  }
@@ -578,7 +655,7 @@ let feedEls = new Map(), toolEls = new Map();
578
655
  function renderSession() {
579
656
  const id = state.route.id, view = $('#view');
580
657
  view.innerHTML = `<section class="chat">
581
- <div class="chat-head"><a class="icon-btn" href="#/sessions" aria-label="Torna a les sessions">${svg(ICON.back)}</a><span id="headBot"></span><div class="grow"><h1 id="sessTitle"></h1><div class="sub" id="sessSub"></div></div><button class="icon-btn" data-act="session-more" aria-label="Opcions de la sessió">${svg(ICON.more)}</button></div>
658
+ <div class="chat-head"><a class="icon-btn" href="#/sessions" aria-label="Torna a les sessions">${svg(ICON.back)}</a><span id="headBot" class="abot"></span><div class="grow"><h1 id="sessTitle"></h1><div class="sub" id="sessSub"></div></div><button class="icon-btn" data-act="session-more" aria-label="Opcions de la sessió">${svg(ICON.more)}</button></div>
582
659
  <div class="mode-bar" id="modeBar"></div>
583
660
  <div class="feed" id="feed" aria-live="polite"></div>
584
661
  <div class="composer" id="composer"></div>
@@ -598,10 +675,10 @@ function renderSessionHead() {
598
675
  if (!bar) return;
599
676
  $('#sessTitle').textContent = s?.title || 'Sessió nova';
600
677
  $('#sessSub').textContent = s ? `${AGENT_NAMES[s.agent] || s.agent} · ${basename(s.cwd)}` : '';
601
- const hb = $('#headBot'); if (hb) { hb.innerHTML = bot(sessionMood(s)); window.NioBot?.refresh(); }
678
+ const hb = $('#headBot'); if (hb) { hb.dataset.agent = s?.agent || ''; hb.innerHTML = bot(sessionMood(s)); window.NioBot?.refresh(); }
602
679
  if (!s) { bar.innerHTML = ''; return; }
603
680
  const ag = state.info?.agents?.find((a) => a.id === s.agent);
604
- bar.innerHTML = `<span class="chip"><span class="agent-tag" data-agent="${esc(s.agent)}">${esc(AGENT_NAMES[s.agent] || s.agent)}</span></span>
681
+ bar.innerHTML = `<span class="chip"><span class="agent-tag nosq" data-agent="${esc(s.agent)}">${agentBot(s.agent, sessionMood(s))}${esc(AGENT_NAMES[s.agent] || s.agent)}</span></span>
605
682
  ${ag?.noModes ? '' : `<button class="chip" data-act="mode" type="button">${esc(MODES[s.mode]?.label || s.mode)}</button>`}
606
683
  <span class="chip mono">${esc(basename(s.cwd))}</span>
607
684
  <button class="chip mono" data-act="model" type="button" title="Canvia de model">${esc(s.model || 'Model per defecte')}</button>
@@ -645,11 +722,11 @@ function eventNode(e) {
645
722
  const s = state.sessions.get(state.route.id);
646
723
  switch (e.kind) {
647
724
  case 'user': return el(`<div class="msg-user">${esc(e.text)}</div>`);
648
- case 'text': return el(`<div class="msg-ai ${e.open && s?.state === 'running' ? 'cursor' : ''}">${md(e.text)}</div>`);
725
+ case 'text': return el(`<div class="msg-ai ${e.open && s?.state === 'running' ? 'cursor' : ''}" data-seq="${e.seq}">${md(e.text)}${!e.open && voiceOn() ? `<button class="speak" type="button" data-speak aria-label="Escolta la resposta">${svg(ICON.speaker)}<span>Escolta</span></button>` : ''}</div>`);
649
726
  case 'tool': return el(`<details class="tool"><summary><span class="name">${esc(e.name)}</span><span class="arg">${esc(toolArg(e.input))}</span><span class="st">…</span></summary><pre>${esc(prettyInput(e.input))}</pre></details>`);
650
727
  case 'tool_result': return el(`<details class="tool"><summary><span class="name">${esc(e.name || 'Eina')}</span><span class="arg"></span><span class="st ${e.error ? 'err' : 'ok'}">${e.error ? '✕' : '✓'}</span></summary><pre class="out">${esc(e.output)}</pre></details>`);
651
728
  case 'approval': { const a = state.approvals.get(e.approval) || {}; return el(approvalCard({ ...a, approval: e.approval, tool: e.tool, input: e.input, state: e.state && e.state !== 'pending' ? e.state : a.state || e.state }, false)); }
652
- case 'error': return el(`<div class="ev-error">${esc(e.text)}</div>`);
729
+ case 'error': return el(`<div class="ev-error" role="alert"><b>Error</b><span>${esc(e.text)}</span></div>`);
653
730
  case 'info': return el(`<div class="ev-info">${esc(e.text)}</div>`);
654
731
  case 'result': {
655
732
  const bits = [];
@@ -669,10 +746,10 @@ function renderTyping() {
669
746
  const last = evs ? [...evs.values()].sort((a, b) => b.seq - a.seq).find((x) => x.kind !== 'status' && x.kind !== 'log') : null;
670
747
  const streaming = last && last.kind === 'text' && last.open;
671
748
  let t = $('.typing', feed);
672
- if (busy && !streaming) { if (!t) { feed.appendChild(el(`<div class="typing">${bot('work')}<span>${esc(AGENT_NAMES[s.agent] || 'L’agent')} està treballant…</span></div>`)); window.NioBot?.refresh(); } }
749
+ if (busy && !streaming) { if (!t) { feed.appendChild(el(`<div class="typing">${agentBot(s.agent, 'work')}<span>${esc(AGENT_NAMES[s.agent] || 'L’agent')} està treballant…</span></div>`)); window.NioBot?.refresh(); } }
673
750
  else if (t) t.remove();
674
751
  if (!busy) $$('.msg-ai.cursor', feed).forEach((n) => n.classList.remove('cursor'));
675
- const hb = $('#headBot .bot'); if (hb) hb.dataset.mood = sessionMood(s);
752
+ $$('.mode-bar .abot .bot, #headBot .bot').forEach((b) => { b.dataset.mood = sessionMood(s); });
676
753
  }
677
754
  /* ---------- Comandes amb «/» (les de nio i les de l'agent) */
678
755
  const NIO_CMDS = [
@@ -732,7 +809,7 @@ function renderComposer() {
732
809
  if (box.dataset.busy === String(busy) && $('textarea', box)) return;
733
810
  box.dataset.busy = String(busy);
734
811
  if (s) loadCmds(s.id).then(() => { if (state.route.id === s.id) drawSlash(); });
735
- box.innerHTML = `<div class="slash" id="slash" role="listbox" hidden></div><div class="inner"><textarea rows="1" id="msg" placeholder="${busy ? 'L’agent està treballant…' : 'Escriu a l’agent… (/ per a comandes)'}" enterkeyhint="send" aria-label="Missatge" aria-controls="slash"></textarea><button class="send ${busy ? 'stop' : ''}" id="sendBtn" type="button" aria-label="${busy ? 'Atura l’agent' : 'Envia'}">${svg(busy ? ICON.stop : ICON.send)}</button></div>`;
812
+ box.innerHTML = `<div class="slash" id="slash" role="listbox" hidden></div><div class="inner"><textarea rows="1" id="msg" placeholder="${busy ? 'L’agent està treballant…' : 'Escriu a l’agent… (/ per a comandes)'}" enterkeyhint="send" aria-label="Missatge" aria-controls="slash"></textarea><button class="mic" id="micBtn" type="button" data-mic aria-label="Dicta amb el micròfon">${svg(ICON.mic)}</button><button class="send ${busy ? 'stop' : ''}" id="sendBtn" type="button" aria-label="${busy ? 'Atura l’agent' : 'Envia'}">${svg(busy ? ICON.stop : ICON.send)}</button></div>`;
736
813
  const ta = $('#msg'); ta.value = prev;
737
814
  const grow = () => autogrow(ta);
738
815
  let items = [], sel = 0;
@@ -776,15 +853,18 @@ function renderComposer() {
776
853
 
777
854
  /* ---------- Fulls */
778
855
  function openSheet(html, onMount, cls) {
779
- const d = $('#sheet'); d.classList.toggle('wiz', cls === 'wiz'); const si = $('#sheetIn'); si.onclick = si.onsubmit = si.oninput = si.onchange = null; si.innerHTML = cls === 'wiz' ? '' : `<div class="grab"></div>${html}`;
780
- if (!d.open) d.showModal(); if (T.on) $('#tour').hidden = true;
856
+ const d = $('#sheet'); d.classList.toggle('wiz', !!cls?.startsWith('wiz')); d.classList.toggle('compact', cls === 'wiz compact'); const si = $('#sheetIn'); si.onclick = si.onsubmit = si.oninput = si.onchange = null; si.innerHTML = cls?.startsWith('wiz') ? '' : `<div class="grab"></div>${html}`;
857
+ if (!d.open) d.showModal(); raiseToasts(); if (T.on) $('#tour').hidden = true;
781
858
  onMount?.($('#sheetIn'));
782
859
  }
783
860
  const closeSheet = () => { const d = $('#sheet'); if (d.open) d.close(); };
784
861
  $('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet') closeSheet(); });
785
862
 
863
+ // Claude Code no deixa «Via lliure» quan nio corre com a root (típic en un VPS).
864
+ const modeBlocked = (agentId, mode) => agentId === 'claude' && mode === 'tot' && state.info?.user === 'root';
786
865
  function modeHelp(agentId, mode) {
787
866
  const a = state.info?.agents?.find((x) => x.id === agentId);
867
+ if (modeBlocked(agentId, mode)) return 'No disponible: Claude Code no ho permet quan s’executa com a root. Fes servir «Demana permís» o engega nio amb un usuari normal.';
788
868
  if (mode === 'llegir') return 'Llegeix el projecte i et proposa canvis, però no toca cap fitxer.';
789
869
  if (mode === 'tot') return 'Treballa sense preguntar. Fes-lo servir només en carpetes on confiïs plenament.';
790
870
  if (a?.approvals) return 'Llegeix sol. Abans d’editar un fitxer o d’executar una ordre, et demana permís aquí.';
@@ -812,7 +892,7 @@ async function newSessionSheet(preset = {}) {
812
892
  const body = () => { $('#wizBody', sh).innerHTML = VIEWS_[step](); after(); };
813
893
  const head = () => {
814
894
  const last = step === STEPS.length - 1;
815
- $('#wizHead', sh).innerHTML = `<div class="grab"></div><div class="row between" style="flex-wrap:nowrap"><h2>Nova sessió</h2><button class="icon-btn" type="button" data-close aria-label="Tanca">×</button></div>
895
+ $('#wizHead', sh).innerHTML = `<div class="grab"></div><div class="row between" style="flex-wrap:nowrap"><h2>Nova sessió</h2><button class="icon-btn" type="button" data-close aria-label="Tanca">${svg(ICON.close)}</button></div>
816
896
  <div class="wiz-dots">${STEPS.map((n, i) => `<button type="button" class="wiz-dot ${i === step ? 'on' : i < step ? 'done' : ''}" data-step="${i}" ${i < step ? '' : 'disabled'} aria-label="${i + 1}. ${n}"></button>`).join('')}</div>
817
897
  <p class="muted small">Pas ${step + 1} de ${STEPS.length} · ${STEPS[step]}</p>`;
818
898
  $('#wizFoot', sh).innerHTML = `<button class="btn ghost" type="button" ${step ? 'data-back' : 'data-close'}>${step ? 'Enrere' : 'Cancel·la'}</button><button class="btn ink" type="button" ${last ? 'data-start' : 'data-next'}>${last ? 'Comença' : 'Continua'}</button>`;
@@ -853,7 +933,7 @@ async function newSessionSheet(preset = {}) {
853
933
  };
854
934
 
855
935
  const VIEWS_ = [
856
- () => `<p class="wiz-q">Quin agent vols fer servir?</p><div class="picker">${agents.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>`,
936
+ () => `<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>`,
857
937
  () => {
858
938
  const crumbs = fsd ? fsd.path.split(/[\\/]/).filter(Boolean) : [];
859
939
  const quick = [...recents.map((p) => ({ path: p, name: basename(p) })), ...projects.filter((p) => !recents.includes(p.path))].slice(0, 8).map((p) => `<button type="button" class="chip" data-fs="${esc(p.path)}">${esc(p.name)}</button>`).join('');
@@ -867,10 +947,11 @@ async function newSessionSheet(preset = {}) {
867
947
  },
868
948
  () => {
869
949
  const ag = agentOf();
950
+ if (modeBlocked(sel.agent, sel.mode)) sel.mode = 'preguntar';
870
951
  const modelHtml = sel.agent === 'ollama'
871
952
  ? `<label class="f"><span>Model</span><select id="modelSel">${(models || []).map((m) => `<option ${m.name === sel.model ? 'selected' : ''}>${esc(m.name)}</option>`).join('') || `<option value="">${models == null ? 'Carregant…' : 'Cap model (ollama pull qwen3:4b)'}</option>`}</select></label>`
872
953
  : `<label class="f"><span>Model <span class="muted">(opcional)</span></span><input type="text" id="modelIn" value="${esc(sel.model)}" placeholder="El que l’agent faci servir per defecte" autocapitalize="off" spellcheck="false"></label>`;
873
- return `<p class="wiz-q">Quins permisos té?</p>${ag?.noModes ? '<p class="muted small">Aquest agent no té modes de permisos.</p>' : `<div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-mode="${k}" aria-pressed="${k === sel.mode}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(sel.agent, k))}</div></button>`).join('')}</div>`}${modelHtml}`;
954
+ return `<p class="wiz-q">Quins permisos té?</p>${ag?.noModes ? '<p class="muted small">Aquest agent no té modes de permisos.</p>' : `<div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-mode="${k}" aria-pressed="${k === sel.mode}" ${modeBlocked(sel.agent, k) ? 'disabled' : ''}><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(sel.agent, k))}</div></button>`).join('')}</div>`}${modelHtml}`;
874
955
  },
875
956
  () => `<p class="wiz-q">Què ha de fer?</p>
876
957
  <dl class="kv"><dt>Agent</dt><dd>${esc(AGENT_NAMES[sel.agent] || sel.agent)}</dd><dt>Carpeta</dt><dd class="mono">${esc(sel.cwd)}</dd>${agentOf()?.noModes ? '' : `<dt>Permisos</dt><dd>${esc(MODES[sel.mode].label)}</dd>`}${sel.model ? `<dt>Model</dt><dd class="mono">${esc(sel.model)}</dd>` : ''}</dl>
@@ -928,14 +1009,14 @@ async function modelSheet(s) {
928
1009
  const row = (v, name, note) => `<button class="card" type="button" data-model="${esc(v)}" aria-pressed="${(s.model || '') === v}"><b>${esc(name)}</b>${note ? `<div class="muted small" style="margin-top:4px">${esc(note)}</div>` : ''}</button>`;
929
1010
  openSheet(`<h2>Model</h2><p class="muted small">${esc(AGENT_NAMES[s.agent] || s.agent)}. El canvi s’aplica a partir del pròxim missatge.</p>
930
1011
  <div class="list">${row('', 'Per defecte', 'El que l’agent faci servir sol')}${picks.map((x) => row(...x)).join('')}</div>
931
- <form class="pair-form" id="modelForm"><input type="text" id="modelFree" placeholder="Un altre model (nom exacte)" value="${picks.some((x) => x[0] === s.model) ? '' : esc(s.model || '')}" autocapitalize="off" spellcheck="false" aria-label="Nom del model"><button class="btn ink" type="submit">Desa</button></form>`, (sh) => {
1012
+ <div class="or"><span>o un altre model</span></div><form class="field-row" id="modelForm"><input type="text" id="modelFree" placeholder="Nom exacte del model" value="${picks.some((x) => x[0] === s.model) ? '' : esc(s.model || '')}" autocapitalize="off" spellcheck="false" aria-label="Nom del model"><button class="btn ink" type="submit">Desa</button></form>`, (sh) => {
932
1013
  const save = async (model) => { try { const sum = await api(`/api/sessions/${s.id}`, { method: 'PATCH', body: { model } }); state.sessions.set(s.id, sum); closeSheet(); renderSessionHead(); } catch (e) { toast(e.message); } };
933
1014
  $$('[data-model]', sh).forEach((b) => (b.onclick = () => save(b.dataset.model)));
934
1015
  $('#modelForm', sh).onsubmit = (e) => { e.preventDefault(); e.stopPropagation(); save($('#modelFree', sh).value.trim()); };
935
1016
  });
936
1017
  }
937
1018
  function modeSheet(s) {
938
- openSheet(`<h2>Permisos</h2><div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-m="${k}" style="${k === s.mode ? 'border-color:var(--fg);box-shadow:inset 0 0 0 1px var(--fg)' : ''}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(s.agent, k))}</div></button>`).join('')}</div><p class="muted small">El canvi s’aplica a partir del pròxim missatge.</p>`, (sh) => {
1019
+ openSheet(`<h2>Permisos</h2><div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-m="${k}" ${modeBlocked(s.agent, k) ? 'disabled' : ''} style="${k === s.mode ? 'border-color:var(--fg);box-shadow:inset 0 0 0 1px var(--fg)' : ''}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(s.agent, k))}</div></button>`).join('')}</div><p class="muted small">El canvi s’aplica a partir del pròxim missatge.</p>`, (sh) => {
939
1020
  $$('[data-m]', sh).forEach((b) => (b.onclick = async () => {
940
1021
  try { const sum = await api(`/api/sessions/${s.id}`, { method: 'PATCH', body: { mode: b.dataset.m } }); state.sessions.set(s.id, sum); closeSheet(); renderSessionHead(); }
941
1022
  catch (e) { toast(e.message); }
@@ -1106,8 +1187,10 @@ window.NioBrains = {
1106
1187
  }
1107
1188
  if (id === 'local') {
1108
1189
  if (!local.engine) throw new Error('Primer obre un model a «Xat local»');
1109
- const r = await local.engine.chat.completions.create({ messages, temperature: 0.2 });
1110
- return r.choices[0]?.message?.content || '';
1190
+ try {
1191
+ const r = await local.engine.chat.completions.create({ messages, temperature: 0.2 });
1192
+ return r.choices[0]?.message?.content || '';
1193
+ } catch (e) { if (gpuLost(e)) { await recoverLocal(''); throw new Error('La GPU del mòbil s’ha reiniciat. Torna-ho a provar quan el model s’hagi carregat.'); } throw e; }
1111
1194
  }
1112
1195
  throw new Error('Aquest model ja no està disponible');
1113
1196
  }
@@ -1169,6 +1252,7 @@ function bindLocal() {
1169
1252
  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>`));
1170
1253
  feed.scrollTop = feed.scrollHeight;
1171
1254
  const ta = $('#lmsg');
1255
+ if (local.draft) { ta.value = local.draft; local.draft = ''; autogrow(ta); }
1172
1256
  ta.addEventListener('input', () => autogrow(ta));
1173
1257
  ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !isTouch) { e.preventDefault(); sendLocal(); } });
1174
1258
  $('#lsend').onclick = () => (local.busy ? stopLocal() : sendLocal());
@@ -1187,6 +1271,15 @@ const LOCAL_SYSTEM = [
1187
1271
  'Si no saps una cosa, digues «no ho sé». No t’inventis dades, noms ni enllaços.',
1188
1272
  'No facis preguntes seguides ni allargassis la conversa. No facis servir emojis.'
1189
1273
  ].join(' ');
1274
+ const gpuLost = (e) => /mapAsync|GPUBuffer|GPUDevice|Instance reference|device (was )?lost|Device is lost|context lost/i.test(`${e?.message || e}`);
1275
+ // La GPU del mòbil es reinicia si l'app va al fons o falta memòria: es deixa l'engine mort i es torna a carregar (els pesos ja són a la memòria cau).
1276
+ async function recoverLocal(text) {
1277
+ const id = local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id);
1278
+ try { await local.engine?.unload?.(); } catch { /* ja és mort */ }
1279
+ local.engine = null; local.model = null; local.draft = text || '';
1280
+ toast('La GPU del mòbil s’ha reiniciat. Torno a carregar el model…', 5000);
1281
+ await loadLocal(id);
1282
+ }
1190
1283
  async function loadLocal(id) {
1191
1284
  local.loading = true; local.progress = 0; local.status = 'Preparant…'; render();
1192
1285
  try {
@@ -1219,15 +1312,91 @@ async function sendLocal() {
1219
1312
  acc += c.choices[0]?.delta?.content || ''; out.innerHTML = md(stripThink(acc)); if (nearBottom(feed)) feed.scrollTop = feed.scrollHeight;
1220
1313
  if (local.stopped) break;
1221
1314
  }
1222
- } catch (e) { if (!local.stopped) acc = acc || `Error: ${e.message}`; out.innerHTML = md(stripThink(acc)); }
1315
+ } catch (e) {
1316
+ if (gpuLost(e) && !acc) { local.messages.pop(); out.remove(); feed.lastElementChild?.remove(); local.busy = false; local.stopped = false; setLocalBusy(false); return recoverLocal(text); }
1317
+ if (!local.stopped) acc = acc || `Error: ${e.message}`; out.innerHTML = md(stripThink(acc));
1318
+ }
1223
1319
  out.classList.remove('cursor');
1224
1320
  if (local.stopped) { try { await local.engine.interruptGenerate(); } catch { /* res */ } }
1321
+ 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(''); }
1225
1322
  local.messages.push({ role: 'assistant', content: stripThink(acc) || '…' }); local.busy = false; local.stopped = false; setLocalBusy(false);
1226
1323
  }
1227
1324
 
1325
+ /* ---------------------------------------------------------------- veu (ElevenLabs) */
1326
+ // La clau viu només en aquest navegador; les crides van directes a ElevenLabs, sense passar per nio.
1327
+ const VOICE_DEFAULT = { key: '', voice: 'JBFqnCBsd6RMkjVDRZzb', model: 'eleven_v3' };
1328
+ const voiceCfg = () => ({ ...VOICE_DEFAULT, ...LS.get('nio.voice', {}) });
1329
+ const voiceOn = () => !!voiceCfg().key;
1330
+ async function elevenlabs(path, init = {}) {
1331
+ const res = await fetch(`https://api.elevenlabs.io${path}`, { ...init, headers: { 'xi-api-key': voiceCfg().key, ...(init.headers || {}) } });
1332
+ if (res.ok) return res;
1333
+ let why = ''; try { const j = await res.json(); why = j.detail?.message || (typeof j.detail === 'string' ? j.detail : ''); } catch { /* sense cos */ }
1334
+ throw new Error(res.status === 401 ? 'La clau d’ElevenLabs no és vàlida.' : res.status === 429 ? 'ElevenLabs diu que has superat el límit. Prova-ho d’aquí una estona.' : `ElevenLabs: ${why || `error ${res.status}`}`);
1335
+ }
1336
+ const speech = { audio: null, btn: null, cache: new Map() };
1337
+ const speechText = (t) => String(t).replace(/```[\s\S]*?```/g, ' (bloc de codi) ').replace(/`([^`]+)`/g, '$1').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/^#{1,4}\s+/gm, '').replace(/^\s*[-*•]\s+/gm, '').trim().slice(0, 4500);
1338
+ function stopSpeech() { speech.audio?.pause(); speech.audio = null; speech.btn?.classList.remove('on', 'wait'); speech.btn = null; }
1339
+ async function speak(btn) {
1340
+ if (speech.btn === btn) return stopSpeech();
1341
+ stopSpeech();
1342
+ const ev = state.events.get(state.route.id)?.get(Number(btn.closest('[data-seq]')?.dataset.seq)), text = speechText(ev?.text || '');
1343
+ if (!text) return;
1344
+ speech.btn = btn; btn.classList.add('wait');
1345
+ try {
1346
+ const c = voiceCfg(), k = `${c.voice}|${c.model}|${text}`; let url = speech.cache.get(k);
1347
+ if (!url) {
1348
+ const res = await elevenlabs(`/v1/text-to-speech/${encodeURIComponent(c.voice)}?output_format=mp3_44100_64`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, model_id: c.model }) });
1349
+ url = URL.createObjectURL(await res.blob()); speech.cache.set(k, url);
1350
+ }
1351
+ if (speech.btn !== btn) return;
1352
+ const a = new Audio(url); speech.audio = a; a.onended = stopSpeech; await a.play();
1353
+ btn.classList.remove('wait'); btn.classList.add('on');
1354
+ } catch (e) { stopSpeech(); toast(e.message, 4500); }
1355
+ }
1356
+ const mic = { rec: null, chunks: [], stream: null };
1357
+ async function toggleMic() {
1358
+ if (!voiceOn()) return voiceSheet();
1359
+ if (mic.rec) return mic.rec.stop();
1360
+ if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return toast('Aquest navegador no pot gravar àudio.');
1361
+ try { mic.stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch { return toast('No tinc permís per al micròfon. Activa’l als ajustos del navegador.', 4500); }
1362
+ const rec = new MediaRecorder(mic.stream); mic.rec = rec; mic.chunks = [];
1363
+ rec.ondataavailable = (e) => { if (e.data.size) mic.chunks.push(e.data); };
1364
+ rec.onstop = async () => {
1365
+ mic.stream.getTracks().forEach((t) => t.stop()); mic.rec = null;
1366
+ const blob = new Blob(mic.chunks, { type: rec.mimeType || 'audio/webm' });
1367
+ $('#micBtn')?.classList.remove('rec'); $('#micBtn')?.classList.add('wait');
1368
+ try {
1369
+ if (blob.size < 1500) throw new Error('No he sentit res. Prova-ho una altra vegada.');
1370
+ const fd = new FormData(); fd.append('model_id', 'scribe_v1'); fd.append('file', blob, 'veu.webm');
1371
+ const r = await (await elevenlabs('/v1/speech-to-text', { method: 'POST', body: fd })).json(), ta = $('#msg');
1372
+ if (!r.text?.trim()) toast('No he entès res.');
1373
+ else if (ta) { ta.value = `${ta.value ? `${ta.value} ` : ''}${r.text.trim()}`; ta.dispatchEvent(new Event('input')); ta.focus(); }
1374
+ } catch (e) { toast(e.message, 4500); }
1375
+ $('#micBtn')?.classList.remove('wait');
1376
+ };
1377
+ rec.start(); $('#micBtn')?.classList.add('rec');
1378
+ }
1379
+ function voicePanel() {
1380
+ return `<section class="panel"><header><h2>Veu</h2>${voiceOn() ? '<span class="chip ok"><i class="dot on"></i>ElevenLabs</span>' : ''}</header><div class="body stack"><p class="small">Dicta els missatges amb el micròfon i escolta les respostes de l’agent, amb ElevenLabs.</p><div class="row"><button class="btn ink" data-act="voice" type="button">${voiceOn() ? 'Canvia la configuració' : 'Configura la veu'}</button></div></div></section>`;
1381
+ }
1382
+ function voiceSheet() {
1383
+ const c = voiceCfg();
1384
+ openSheet(`<h2>Veu amb ElevenLabs</h2><p class="muted small">Dicta amb el micròfon i escolta les respostes. La clau es queda només en aquest navegador: les crides van directes a ElevenLabs, nio no les veu.</p>
1385
+ <form class="stack" id="voiceForm" style="margin-top:14px">
1386
+ <label class="f"><span>Clau d’API</span><input type="password" id="vKey" value="${esc(c.key)}" placeholder="sk_…" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
1387
+ <label class="f"><span>Identificador de la veu</span><input type="text" id="vVoice" value="${esc(c.voice)}" autocomplete="off" autocapitalize="off" spellcheck="false"></label>
1388
+ <label class="f"><span>Model</span><select id="vModel"><option value="eleven_v3" ${c.model === 'eleven_v3' ? 'selected' : ''}>v3 (inclou català)</option><option value="eleven_multilingual_v2" ${c.model === 'eleven_multilingual_v2' ? 'selected' : ''}>Multilingual v2</option></select></label>
1389
+ <div class="row between sheet-foot"><button class="btn ghost" type="button" id="vOff" ${c.key ? '' : 'disabled'}>Treu la clau</button><button class="btn ink" type="submit">Desa</button></div>
1390
+ </form>`, (sh) => {
1391
+ const save = (v) => { LS.set('nio.voice', v); closeSheet(); if (state.route.name === 'session') renderSession(); else render(); };
1392
+ $('#voiceForm', sh).onsubmit = (e) => { e.preventDefault(); const key = $('#vKey', sh).value.trim(); if (!key) return toast('Enganxa la clau d’API.'); save({ key, voice: $('#vVoice', sh).value.trim() || VOICE_DEFAULT.voice, model: $('#vModel', sh).value }); toast('Veu activada'); };
1393
+ $('#vOff', sh).onclick = () => { save({ ...c, key: '' }); toast('Clau eliminada'); };
1394
+ });
1395
+ }
1396
+
1228
1397
  /* ---------------------------------------------------------------- accions */
1229
1398
  document.addEventListener('click', async (e) => {
1230
- const a = e.target.closest('[data-act], [data-copy], [data-decide], [data-machine], [data-forget], [data-reconnect], [data-lm]');
1399
+ const a = e.target.closest('[data-act], [data-speak], [data-mic], [data-copy], [data-decide], [data-machine], [data-forget], [data-rename], [data-reconnect], [data-lm]');
1231
1400
  if (!a) return;
1232
1401
  if (a.dataset.copy) {
1233
1402
  try { await navigator.clipboard.writeText(a.dataset.copy); } catch { return toast(a.dataset.copy); }
@@ -1238,9 +1407,12 @@ document.addEventListener('click', async (e) => {
1238
1407
  setTimeout(() => { a.innerHTML = html; a.classList.remove('copied'); delete a.dataset.copied; }, 1700);
1239
1408
  return;
1240
1409
  }
1410
+ if (a.dataset.speak != null) return speak(a);
1411
+ if (a.dataset.mic != null) return toggleMic();
1241
1412
  if (a.dataset.decide) return decide(a.closest('[data-approval]'), a.dataset.decide);
1242
1413
  if (a.dataset.machine != null) return switchMachine(Number(a.dataset.machine));
1243
- if (a.dataset.reconnect != null) { const m = store.machines[Number(a.dataset.reconnect)]; return addComputerSheet('vps', { target: m.via, auto: true }); }
1414
+ if (a.dataset.reconnect != null) { const m = store.machines[Number(a.dataset.reconnect)]; reconTry.delete(m.via); if (sshHelper()) { autoReconnect({ ...m }); return; } return addComputerSheet('vps', { target: m.via, auto: true }); }
1415
+ if (a.dataset.rename != null) return renameSheet(Number(a.dataset.rename));
1244
1416
  if (a.dataset.forget != null) { if (confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) forgetMachine(Number(a.dataset.forget)); return; }
1245
1417
  if (a.dataset.lm) { if (local.loading) return; LS.set('nio.localModel', a.dataset.lm); return render(); }
1246
1418
  switch (a.dataset.act) {
@@ -1259,7 +1431,7 @@ document.addEventListener('click', async (e) => {
1259
1431
  case 'push-test': try { const r = await api('/api/push/test', { method: 'POST' }); toast(r.subs ? 'Enviat. Arribarà en uns segons.' : 'Primer activa els avisos'); } catch (err) { toast(err.message); } return;
1260
1432
  case 'add-computer': return addComputerSheet();
1261
1433
  case 'open-sessions': return go('#/sessions');
1262
- case 'help': go('#/ordinadors'); setTimeout(() => { const h = $('#help'); h?.scrollIntoView({ block: 'start' }); $('details', h)?.setAttribute('open', ''); }, 60); return;
1434
+ case 'help': return go('#/ajuda');
1263
1435
  case 'more': return moreSheet();
1264
1436
  case 'machines': return machinesSheet();
1265
1437
  case 'mobile-qr': return mobileQrSheet();
@@ -1270,6 +1442,7 @@ document.addEventListener('click', async (e) => {
1270
1442
  case 'lm-clear': local.messages = []; return render();
1271
1443
  case 'palette': return palette();
1272
1444
  case 'tour': return tourStart();
1445
+ case 'voice': return voiceSheet();
1273
1446
  }
1274
1447
  });
1275
1448
  document.addEventListener('keydown', (e) => { const t = e.target; if ((e.key === 'Enter' || e.key === ' ') && t.classList?.contains('switch') && t.dataset.act) { e.preventDefault(); t.click(); } });
@@ -1285,11 +1458,11 @@ document.addEventListener('submit', (e) => {
1285
1458
  /* ---------------------------------------------------------------- paleta d'ordres */
1286
1459
  let palSel = 0;
1287
1460
  function commands() {
1288
- const c = Object.entries(VIEWS).map(([k, v]) => ({ label: `Ves a ${v.label}`, hint: 'Navega', run: () => go(HASH[k]) }));
1461
+ const c = Object.entries(VIEWS).filter(([k]) => !(k === 'models' && mq.matches)).map(([k, v]) => ({ label: `Ves a ${v.label}`, hint: 'Navega', run: () => go(HASH[k]) }));
1289
1462
  if (machine()) c.push({ label: 'Nova sessió', hint: 'Crea', run: newSessionSheet });
1290
1463
  c.push({ label: 'Afegeix un ordinador', hint: 'Crea', run: () => addComputerSheet() });
1291
1464
  [...state.sessions.values()].slice(0, 20).forEach((s) => c.push({ label: s.title || 'Sessió nova', hint: AGENT_NAMES[s.agent] || s.agent, run: () => go(`#/s/${s.id}`) }));
1292
- c.push({ label: 'Com es fa (ajuda)', hint: 'Ajuda', run: () => { go('#/ordinadors'); setTimeout(() => $('#help')?.scrollIntoView({ block: 'start' }), 60); } });
1465
+ c.push({ label: 'Configura la veu (ElevenLabs)', hint: 'Ajustos', run: voiceSheet });
1293
1466
  c.push({ label: 'Fes el tutorial', hint: 'Ajuda', run: tourStart });
1294
1467
  return c;
1295
1468
  }
@@ -1347,12 +1520,12 @@ const TSTEPS = [
1347
1520
  { 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.' },
1348
1521
  { 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.' },
1349
1522
  { 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.' },
1350
- { route: 'models', skipIf: () => !machine(), target: '.side nav a[href="#/models"]', m: '#tabbar [data-act=more]', title: 'Models al teu ordinador', text: 'Si tens Ollama, aquí descarregues models i veus quins tens. Res no surt de casa i no gasta res.' },
1523
+ { route: 'models', skipIf: () => !machine() || mq.matches, target: '.side nav a[href="#/models"]', m: '#tabbar [data-act=more]', title: 'Models al teu ordinador', text: 'Si tens Ollama, aquí descarregues models i veus quins tens. Res no surt de casa i no gasta res.' },
1351
1524
  { route: 'mobil', target: '.side nav a[href="#/xat-local"]', m: '.page .model-card', title: 'Un model sense internet', text: 'Si no tens l’ordinador a mà, pots fer servir un model petit que funciona dins del mateix mòbil.' },
1352
1525
  { 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.' },
1353
- { route: 'ordinadors', target: '#help', m: '#help', title: 'Si alguna cosa no va', text: 'A «Com es fa» tens cada pas explicat, què cal al servidor i què mirar si no connecta.' },
1354
- { 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 els models, el xat local, la pissarra, l’ajuda i aquest tutorial.' : 'Prem ⌘K (o Ctrl+K) per anar a qualsevol lloc o obrir una sessió sense tocar el ratolí.') },
1355
- { center: true, mood: 'happy', title: 'Ja està tot a punt.', text: 'Si mai vols repassar-ho, tens el tutorial al menú i l’ajuda a Ordinadors.', next: 'Finalitza' }
1526
+ { 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.' },
1527
+ { 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í.') },
1528
+ { center: true, mood: 'happy', title: 'Ja està tot a punt.', text: 'Si mai vols repassar-ho, tens el tutorial a Ajuda.', next: 'Finalitza' }
1356
1529
  ];
1357
1530
  const tval = (v) => (typeof v === 'function' ? v() : v);
1358
1531
  function tourStart() { T.on = true; T.seen = true; tourGo(0); }