nio-cat 0.4.4 → 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/bin/nio.js CHANGED
@@ -9,7 +9,7 @@ const config = require('../src/config');
9
9
  const { Hub } = require('../src/hub');
10
10
  const { createServer } = require('../src/server');
11
11
  const { detectAll } = require('../src/agents');
12
- const { startTunnel } = require('../src/tunnel');
12
+ const { startTunnel, startNamedTunnel } = require('../src/tunnel');
13
13
  const { addServer } = require('../src/ssh');
14
14
  const { pairLink, terminalQr, writePairPage, makeCode, codeLink, terminalQrSize } = require('../src/pair');
15
15
  const { which } = require('../src/proc');
@@ -22,7 +22,7 @@ for (let i = 0; i < argv.length; i++) {
22
22
  if (a.startsWith('--')) {
23
23
  const [k, v] = a.slice(2).split('=');
24
24
  if (v !== undefined) flags[k] = v;
25
- else if (['port', 'url', 'app', 'local-port', 'dir'].includes(k)) flags[k] = argv[++i];
25
+ else if (['port', 'url', 'app', 'local-port', 'dir', 'tunnel-token'].includes(k)) flags[k] = argv[++i];
26
26
  else flags[k] = true;
27
27
  } else pos.push(a);
28
28
  }
@@ -153,6 +153,20 @@ async function start() {
153
153
  console.log(c.dim(' Per connectar-t’hi des de qualsevol lloc, instal·la’l (a macOS: brew install cloudflared) i torna a engegar nio.'));
154
154
  }
155
155
 
156
+ // Adreça fixa: túnel amb nom de Cloudflare. Es desa i els reinicis la mantenen.
157
+ if (flags['tunnel-token'] && flags.url) {
158
+ cfg.tunnelToken = flags['tunnel-token'];
159
+ cfg.fixedUrl = flags.url.replace(/\/+$/, '');
160
+ config.save(cfg);
161
+ delete flags.url;
162
+ }
163
+ if (flags['forget-fixed']) {
164
+ delete cfg.tunnelToken;
165
+ delete cfg.fixedUrl;
166
+ config.save(cfg);
167
+ }
168
+ const fixed = cfg.tunnelToken && cfg.fixedUrl && !flags.url && !flags['no-tunnel'] && !flags.lan ? cfg : null;
169
+
156
170
  const hub = new Hub(cfg);
157
171
  let publicUrl = flags.url || null;
158
172
  const host = flags.lan ? '0.0.0.0' : '127.0.0.1';
@@ -188,6 +202,17 @@ async function start() {
188
202
  return showPair(publicUrl);
189
203
  }
190
204
 
205
+ if (fixed) {
206
+ const nt = startNamedTunnel(cfg.tunnelToken, () => {
207
+ publicUrl = cfg.fixedUrl;
208
+ console.log(` ${c.dim('Adreça fixa:')} ${publicUrl}`);
209
+ cfg.lastUrl = publicUrl;
210
+ config.save(cfg);
211
+ showPair(publicUrl);
212
+ });
213
+ if (nt) return console.log(c.dim(' Obrint el túnel fix…'));
214
+ }
215
+
191
216
  const t = startTunnel(cfg.port, (url) => {
192
217
  publicUrl = url;
193
218
  console.log(` ${c.dim('Túnel:')} ${url} ${c.dim('(Cloudflare, sense cost)')}`);
@@ -242,6 +267,8 @@ async function start() {
242
267
  --lan Connexió dins la mateixa wifi
243
268
  --url <adreça> Una adreça pública teva (Tailscale o similar)
244
269
  --no-tunnel Només des d'aquest ordinador
270
+ --tunnel-token <t> --url <adreça> Adreça fixa amb un túnel de Cloudflare amb nom (es desa; la propera vegada n'hi ha prou amb nio)
271
+ --forget-fixed Oblida l'adreça fixa i torna al túnel temporal
245
272
  --port <n> Port local (7717 si no dius res)
246
273
  --no-open No obris el panell al navegador d'aquest ordinador
247
274
  --local Obre la còpia del panell d'aquest ordinador (si el navegador bloqueja nio.cat, p. ex. Safari)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "description": "nio: fes servir Claude Code, Codex, Grok, Antigravity i Ollama del teu ordinador des del mòbil.",
5
5
  "license": "MIT",
6
6
  "author": "f.cat",
package/src/server.js CHANGED
@@ -143,6 +143,7 @@ const MODEL_NAME = /^[\w][\w.\-/]{0,100}(:[\w.\-]{1,60})?$/;
143
143
 
144
144
  function createServer({ cfg, hub, getPublicUrl, getApp }) {
145
145
  const streams = new Set();
146
+ const seenDevices = new Map(); // mòbils que s'han connectat (per avisar l'ordinador que mostra el QR)
146
147
  const pulls = new Map(); // descàrregues de models d'Ollama en curs
147
148
 
148
149
  hub.on('out', (msg) => {
@@ -388,6 +389,12 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
388
389
  connection: 'keep-alive',
389
390
  });
390
391
  res.write(`data: ${JSON.stringify({ type: 'hello', sessions: hub.list(), approvals: hub.pendingApprovals() })}\n\n`);
392
+ const dev = String(url.searchParams.get('d') || '').slice(0, 40), kind = url.searchParams.get('k') === 'phone' ? 'phone' : 'desktop';
393
+ if (dev && kind === 'phone' && Date.now() - (seenDevices.get(dev) || 0) > 10 * 60 * 1000) {
394
+ const data = `data: ${JSON.stringify({ type: 'device', device: dev, kind })}\n\n`;
395
+ for (const o of streams) o.write(data);
396
+ }
397
+ if (dev) seenDevices.set(dev, Date.now());
391
398
  streams.add(res);
392
399
  req.on('close', () => streams.delete(res));
393
400
  return;
@@ -396,12 +403,14 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
396
403
  if (url.pathname === '/api/ssh/add' && req.method === 'POST') {
397
404
  res.writeHead(200, { 'content-type': 'application/x-ndjson; charset=utf-8', 'cache-control': 'no-store', 'x-accel-buffering': 'no' });
398
405
  const send = (o) => res.write(JSON.stringify(o) + '\n');
406
+ const beat = setInterval(() => send({ ping: 1 }), 8000);
399
407
  try {
400
408
  const b = await readBody(req);
401
409
  send({ done: true, ...(await addServer(String(b.target || '').trim(), { onStep: (step) => send({ step }) })) });
402
410
  } catch (e) {
403
411
  send({ error: e.message });
404
412
  }
413
+ clearInterval(beat);
405
414
  return res.end();
406
415
  }
407
416
  try {
package/src/tunnel.js CHANGED
@@ -25,4 +25,25 @@ function startTunnel(port, onUrl) {
25
25
  return child;
26
26
  }
27
27
 
28
- module.exports = { startTunnel };
28
+ // Túnel amb nom (compte gratuït de Cloudflare): l'adreça no canvia entre reinicis.
29
+ function startNamedTunnel(token, onReady) {
30
+ const bin = which('cloudflared');
31
+ if (!bin) return null;
32
+ const child = spawn(bin, ['tunnel', '--no-autoupdate', 'run', '--token', token], {
33
+ env: childEnv(),
34
+ stdio: ['ignore', 'pipe', 'pipe'],
35
+ });
36
+ let ready = false;
37
+ const scan = (d) => {
38
+ if (!ready && /Registered tunnel connection/i.test(String(d))) {
39
+ ready = true;
40
+ onReady();
41
+ }
42
+ };
43
+ child.stdout.on('data', scan);
44
+ child.stderr.on('data', scan);
45
+ process.on('exit', () => child.kill());
46
+ return child;
47
+ }
48
+
49
+ module.exports = { startTunnel, startNamedTunnel };
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,6 +31,7 @@ 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"/>',
34
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"/>',
@@ -59,9 +62,26 @@ const machine = () => store.machines[store.active] || store.machines[0] || null;
59
62
  /* ---------------------------------------------------------------- utilitats */
60
63
  function toast(msg, ms = 2800) {
61
64
  const box = $('#toasts'), t = document.createElement('div');
62
- t.className = 'toast'; t.textContent = msg;
63
- while (box.children.length >= 2) box.firstElementChild.remove();
64
- 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 {}
65
85
  }
66
86
  function ago(ts) {
67
87
  const s = Math.round((Date.now() - ts) / 1000);
@@ -125,7 +145,7 @@ async function connect() {
125
145
  const ctrl = new AbortController(); streamCtrl = ctrl;
126
146
  try {
127
147
  state.info = await api('/api/info');
128
- 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' });
129
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 });
130
150
  if (!res.ok || !res.body) throw new Error(`Error ${res.status}`);
131
151
  state.connected = true; state.lastError = null; retry = 0;
@@ -147,13 +167,37 @@ async function connect() {
147
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;
148
168
  render();
149
169
  if (e.fatal) return;
170
+ if (m.kind === 'vps' && m.via && retry >= 2) autoReconnect(m);
150
171
  setTimeout(() => machine() && !ctrl.signal.aborted && connect(), Math.min(15000, 1000 * 2 ** retry++));
151
172
  }
152
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
+ }
153
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).
154
198
  function renameMachine(host, id) {
155
199
  let ms = store.machines; const cur = ms[store.active]; if (!cur) return;
156
- if (host) cur.name = host;
200
+ if (host && !cur.renamed) cur.name = host;
157
201
  if (id) {
158
202
  cur.id = id;
159
203
  const keep = cur.url, list = ms.filter((m) => m === cur || m.id !== id);
@@ -165,6 +209,9 @@ function renameMachine(host, id) {
165
209
  function handle(msg) {
166
210
  const r = state.route;
167
211
  switch (msg.type) {
212
+ case 'device':
213
+ if (!isPhone() && msg.device !== deviceId()) { if ($('#qrBox')) closeSheet(); toast('Mòbil connectat correctament', 2000); }
214
+ break;
168
215
  case 'hello':
169
216
  state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
170
217
  state.approvals = new Map(msg.approvals.map((a) => [a.id, a]));
@@ -266,9 +313,11 @@ const VIEWS = {
266
313
  ordinadors: { label: 'Ordinadors', icon: ICON.machine, group: 'Ordinadors' },
267
314
  models: { label: 'Models', icon: ICON.models, group: 'Ordinadors' },
268
315
  mobil: { label: 'Xat local', icon: ICON.phone, group: 'Eines' },
269
- 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' }
270
319
  };
271
- 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' };
272
321
  const NEEDS_MACHINE = new Set(['sessions', 'approvals', 'models', 'session']);
273
322
  function parseRoute() {
274
323
  const h = location.hash.replace(/^#\/?/, '');
@@ -300,7 +349,7 @@ function renderChrome() {
300
349
  const m = machine(), sc = $('#sideConn');
301
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)}`;
302
351
  sc.setAttribute('role', 'button'); sc.tabIndex = 0; sc.classList.add('switch'); sc.dataset.act = 'machines'; sc.title = 'Ordinadors';
303
- const more = ['models', 'mobil', 'pissarra'].includes(r.name);
352
+ const more = ['mobil', 'pissarra', 'ajustos', 'ajuda'].includes(r.name);
304
353
  $('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['approvals', ICON.approvals, 'Aprovacions', active === 'approvals'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
305
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('')
306
355
  + `<button type="button" data-act="more" ${more ? 'aria-current="page"' : ''}>${svg(ICON.more)}<span>Més</span></button>`;
@@ -326,7 +375,7 @@ function render() {
326
375
  else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
327
376
  else {
328
377
  const html = r.name === 'mobil' ? viewLocal() : null;
329
- 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>`;
330
379
  if (r.name === 'mobil') bindLocal();
331
380
  if (r.name === 'models' && machine()) loadOllama();
332
381
  }
@@ -336,6 +385,7 @@ function render() {
336
385
  }
337
386
 
338
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; };
339
389
  const isPhone = () => isTouch || matchMedia('(max-width: 820px)').matches;
340
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>`;
341
391
  function computerCard(m, i) {
@@ -343,7 +393,7 @@ function computerCard(m, i) {
343
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>`) : '';
344
394
  return `<div class="card computer ${on ? 'active' : ''}">
345
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>
346
- <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>
347
397
  </div>`;
348
398
  }
349
399
  // Ajuda sempre a mà: com es fa cada cosa, i què mirar si no connecta.
@@ -356,9 +406,16 @@ function helpPanel(open) {
356
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>`)}
357
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>`)}
358
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>`)}
359
- <div class="row"><button class="btn sm" type="button" data-act="tour">Fes el tutorial</button></div>
360
409
  </div></section>`;
361
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
+ }
362
419
  function viewComputers() {
363
420
  const ms = store.machines, m = machine(), info = state.info;
364
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>`;
@@ -366,19 +423,16 @@ function viewComputers() {
366
423
  const steps = isPhone()
367
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.']]
368
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.']];
369
- 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>`;
370
427
  }
371
428
  const detail = m && state.connected ? `<h2 class="sec">${esc(m.name)}</h2>
372
- <div class="grid2">
429
+ <div class="grid2 one">
373
430
  <div class="stack">
374
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>
375
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>` : ''}
376
433
  </div>
377
- <div class="stack">
378
- <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>
379
- </div>
380
434
  </div>` : '';
381
- return `${head}<div class="computers">${ms.map(computerCard).join('')}</div>${detail}${voicePanel()}${helpPanel()}`;
435
+ return `${head}<div class="computers">${ms.map(computerCard).join('')}</div>${detail}`;
382
436
  }
383
437
 
384
438
  /* ---------- Afegir un ordinador: aquest, un servidor per SSH o un codi */
@@ -390,8 +444,12 @@ async function probeLocal() {
390
444
  } catch { return null; }
391
445
  }
392
446
  // Afegeix un servidor per SSH des de l'ordinador actiu. El progrés arriba línia a línia.
393
- async function sshAdd(target, onStep) {
394
- 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');
395
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 }) });
396
454
  if (!res.ok || !res.body) throw new Error((await res.json().catch(() => ({}))).error || 'Aquest ordinador no pot afegir servidors. Actualitza nio.');
397
455
  const reader = res.body.getReader(), dec = new TextDecoder(); let buf = '', last = null;
@@ -400,7 +458,7 @@ async function sshAdd(target, onStep) {
400
458
  buf += dec.decode(value, { stream: true }); let i;
401
459
  while ((i = buf.indexOf('\n')) >= 0) {
402
460
  const o = JSON.parse(buf.slice(0, i)); buf = buf.slice(i + 1);
403
- if (o.step) onStep(o.step); else last = o;
461
+ if (o.step) onStep(o.step); else if (!o.ping) last = o;
404
462
  }
405
463
  }
406
464
  if (!last || last.error) throw new Error(last?.error || 'S’ha tallat la connexió');
@@ -415,56 +473,59 @@ async function startScan(video, onText) {
415
473
  }
416
474
  function addComputerSheet(tab, opts = {}) {
417
475
  const phone = isPhone(), canScan = 'BarcodeDetector' in window && !!navigator.mediaDevices?.getUserMedia;
418
- 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]];
419
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>`;
420
481
  openSheet('', (sh) => {
421
- 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>`;
422
483
  const body = $('#addBody', sh);
423
484
  const stop = () => { clearTimeout(poll); scan?.stop(); scan = null; shown = ''; };
424
485
  const set = (key, html) => { if (key === shown) return false; shown = key; body.innerHTML = html; return true; };
425
486
  const draw = () => {
426
- 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(() => {});
427
488
  $$('#addTabs button', sh).forEach((b) => b.setAttribute('aria-pressed', b.dataset.tab === cur));
428
489
  body.scrollTop = 0;
429
490
  if (cur === 'local') local(); else if (cur === 'vps') vps(); else code();
430
491
  };
431
492
  const local = async () => {
432
- 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…'));
433
494
  const d = await probeLocal(); if (cur !== 'local') return;
434
495
  const saved = d && store.machines.findIndex((x) => x.id === d.id);
435
- 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>`);
436
- 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')}`);
437
- 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>`);
438
499
  if (saved < 0 || !d) poll = setTimeout(local, 3000);
439
500
  };
440
501
  const runSsh = async (target) => {
441
- 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';
442
503
  const add = (t) => { $$('li.on', steps).forEach((l) => { l.className = 'ok'; }); steps.insertAdjacentHTML('beforeend', `<li class="on">${esc(t)}…</li>`); };
443
504
  try {
444
- const r = await sshAdd(target, add);
505
+ add(`Connectant amb ${target}`);
506
+ const r = await sshAdd(target, add, sshHelper());
445
507
  LS.set('nio.lastSsh', target);
446
508
  const old = store.machines.findIndex((x) => x.via === target && x.kind === 'vps');
447
- 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)); }
448
- 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; }
449
512
  shown = 'vps-ok';
450
- 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>`;
451
- } 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'; }
452
515
  };
453
516
  const vps = () => {
454
- const m = machine();
455
- 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>'}`);
456
- set('vps', `<p class="wiz-q">Un servidor amb Claude Code</p>
457
- <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>
458
- ${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>` : ''}
459
- <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>` : ''}
460
522
  <ul class="steps" id="sshSteps"></ul>
461
- <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>`);
462
524
  };
463
525
  const code = () => {
464
- set('code', `<p class="wiz-q">${phone ? 'Escaneja o escriu el codi' : 'Escriu el codi'}</p>
465
- <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>
466
- ${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>' : ''}
467
- <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>' : ''}`);
468
529
  };
469
530
  sh.onclick = async (e) => {
470
531
  const t = e.target.closest('[data-tab]'); if (t) { cur = t.dataset.tab; return draw(); }
@@ -484,15 +545,29 @@ function addComputerSheet(tab, opts = {}) {
484
545
  };
485
546
  draw();
486
547
  if (opts.auto && cur === 'vps') setTimeout(() => runSsh(opts.target), 50);
487
- }, 'wiz');
548
+ }, 'wiz compact');
488
549
  }
489
550
  function machinesSheet() {
490
551
  const ms = store.machines;
491
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>'}
492
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)); });
493
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
+ }
494
568
  function moreSheet() {
495
- 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('')}<button class="card row" type="button" data-act="voice" style="gap:12px">${svg(ICON.mic)}<b>Veu</b></button><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(); }; });
496
571
  }
497
572
 
498
573
  /* ---------- Sessions */
@@ -778,8 +853,8 @@ function renderComposer() {
778
853
 
779
854
  /* ---------- Fulls */
780
855
  function openSheet(html, onMount, cls) {
781
- 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}`;
782
- 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;
783
858
  onMount?.($('#sheetIn'));
784
859
  }
785
860
  const closeSheet = () => { const d = $('#sheet'); if (d.open) d.close(); };
@@ -817,7 +892,7 @@ async function newSessionSheet(preset = {}) {
817
892
  const body = () => { $('#wizBody', sh).innerHTML = VIEWS_[step](); after(); };
818
893
  const head = () => {
819
894
  const last = step === STEPS.length - 1;
820
- $('#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>
821
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>
822
897
  <p class="muted small">Pas ${step + 1} de ${STEPS.length} · ${STEPS[step]}</p>`;
823
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>`;
@@ -858,7 +933,7 @@ async function newSessionSheet(preset = {}) {
858
933
  };
859
934
 
860
935
  const VIEWS_ = [
861
- () => `<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>`,
862
937
  () => {
863
938
  const crumbs = fsd ? fsd.path.split(/[\\/]/).filter(Boolean) : [];
864
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('');
@@ -934,7 +1009,7 @@ async function modelSheet(s) {
934
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>`;
935
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>
936
1011
  <div class="list">${row('', 'Per defecte', 'El que l’agent faci servir sol')}${picks.map((x) => row(...x)).join('')}</div>
937
- <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) => {
938
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); } };
939
1014
  $$('[data-model]', sh).forEach((b) => (b.onclick = () => save(b.dataset.model)));
940
1015
  $('#modelForm', sh).onsubmit = (e) => { e.preventDefault(); e.stopPropagation(); save($('#modelFree', sh).value.trim()); };
@@ -1112,8 +1187,10 @@ window.NioBrains = {
1112
1187
  }
1113
1188
  if (id === 'local') {
1114
1189
  if (!local.engine) throw new Error('Primer obre un model a «Xat local»');
1115
- const r = await local.engine.chat.completions.create({ messages, temperature: 0.2 });
1116
- 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; }
1117
1194
  }
1118
1195
  throw new Error('Aquest model ja no està disponible');
1119
1196
  }
@@ -1175,6 +1252,7 @@ function bindLocal() {
1175
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>`));
1176
1253
  feed.scrollTop = feed.scrollHeight;
1177
1254
  const ta = $('#lmsg');
1255
+ if (local.draft) { ta.value = local.draft; local.draft = ''; autogrow(ta); }
1178
1256
  ta.addEventListener('input', () => autogrow(ta));
1179
1257
  ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !isTouch) { e.preventDefault(); sendLocal(); } });
1180
1258
  $('#lsend').onclick = () => (local.busy ? stopLocal() : sendLocal());
@@ -1193,6 +1271,15 @@ const LOCAL_SYSTEM = [
1193
1271
  'Si no saps una cosa, digues «no ho sé». No t’inventis dades, noms ni enllaços.',
1194
1272
  'No facis preguntes seguides ni allargassis la conversa. No facis servir emojis.'
1195
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
+ }
1196
1283
  async function loadLocal(id) {
1197
1284
  local.loading = true; local.progress = 0; local.status = 'Preparant…'; render();
1198
1285
  try {
@@ -1225,9 +1312,13 @@ async function sendLocal() {
1225
1312
  acc += c.choices[0]?.delta?.content || ''; out.innerHTML = md(stripThink(acc)); if (nearBottom(feed)) feed.scrollTop = feed.scrollHeight;
1226
1313
  if (local.stopped) break;
1227
1314
  }
1228
- } 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
+ }
1229
1319
  out.classList.remove('cursor');
1230
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(''); }
1231
1322
  local.messages.push({ role: 'assistant', content: stripThink(acc) || '…' }); local.busy = false; local.stopped = false; setLocalBusy(false);
1232
1323
  }
1233
1324
 
@@ -1286,7 +1377,7 @@ async function toggleMic() {
1286
1377
  rec.start(); $('#micBtn')?.classList.add('rec');
1287
1378
  }
1288
1379
  function voicePanel() {
1289
- return `<section class="panel" style="margin-top:28px"><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>`;
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>`;
1290
1381
  }
1291
1382
  function voiceSheet() {
1292
1383
  const c = voiceCfg();
@@ -1305,7 +1396,7 @@ function voiceSheet() {
1305
1396
 
1306
1397
  /* ---------------------------------------------------------------- accions */
1307
1398
  document.addEventListener('click', async (e) => {
1308
- const a = e.target.closest('[data-act], [data-speak], [data-mic], [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]');
1309
1400
  if (!a) return;
1310
1401
  if (a.dataset.copy) {
1311
1402
  try { await navigator.clipboard.writeText(a.dataset.copy); } catch { return toast(a.dataset.copy); }
@@ -1320,7 +1411,8 @@ document.addEventListener('click', async (e) => {
1320
1411
  if (a.dataset.mic != null) return toggleMic();
1321
1412
  if (a.dataset.decide) return decide(a.closest('[data-approval]'), a.dataset.decide);
1322
1413
  if (a.dataset.machine != null) return switchMachine(Number(a.dataset.machine));
1323
- 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));
1324
1416
  if (a.dataset.forget != null) { if (confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) forgetMachine(Number(a.dataset.forget)); return; }
1325
1417
  if (a.dataset.lm) { if (local.loading) return; LS.set('nio.localModel', a.dataset.lm); return render(); }
1326
1418
  switch (a.dataset.act) {
@@ -1339,7 +1431,7 @@ document.addEventListener('click', async (e) => {
1339
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;
1340
1432
  case 'add-computer': return addComputerSheet();
1341
1433
  case 'open-sessions': return go('#/sessions');
1342
- case 'help': go('#/ordinadors'); setTimeout(() => { const h = $('#help'); h?.scrollIntoView({ block: 'start' }); $('details', h)?.setAttribute('open', ''); }, 60); return;
1434
+ case 'help': return go('#/ajuda');
1343
1435
  case 'more': return moreSheet();
1344
1436
  case 'machines': return machinesSheet();
1345
1437
  case 'mobile-qr': return mobileQrSheet();
@@ -1366,11 +1458,10 @@ document.addEventListener('submit', (e) => {
1366
1458
  /* ---------------------------------------------------------------- paleta d'ordres */
1367
1459
  let palSel = 0;
1368
1460
  function commands() {
1369
- 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]) }));
1370
1462
  if (machine()) c.push({ label: 'Nova sessió', hint: 'Crea', run: newSessionSheet });
1371
1463
  c.push({ label: 'Afegeix un ordinador', hint: 'Crea', run: () => addComputerSheet() });
1372
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}`) }));
1373
- c.push({ label: 'Com es fa (ajuda)', hint: 'Ajuda', run: () => { go('#/ordinadors'); setTimeout(() => $('#help')?.scrollIntoView({ block: 'start' }), 60); } });
1374
1465
  c.push({ label: 'Configura la veu (ElevenLabs)', hint: 'Ajustos', run: voiceSheet });
1375
1466
  c.push({ label: 'Fes el tutorial', hint: 'Ajuda', run: tourStart });
1376
1467
  return c;
@@ -1429,12 +1520,12 @@ const TSTEPS = [
1429
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.' },
1430
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.' },
1431
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.' },
1432
- { 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.' },
1433
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.' },
1434
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.' },
1435
- { 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.' },
1436
- { 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í.') },
1437
- { 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' }
1438
1529
  ];
1439
1530
  const tval = (v) => (typeof v === 'function' ? v() : v);
1440
1531
  function tourStart() { T.on = true; T.seen = true; tourGo(0); }
@@ -39,7 +39,7 @@
39
39
  <body>
40
40
  <header class="mbar">
41
41
  <button class="icon-btn burger" id="burger" type="button" aria-label="Obre el menú" aria-expanded="false" aria-controls="side"><span></span><span></span><span></span></button>
42
- <a href="/" class="word" aria-label="nio">ni<span class="bot" data-mood="idle" aria-hidden="true"><span class="eyes"><i></i><i></i></span></span></a>
42
+ <a href="/app" class="word" aria-label="nio">ni<span class="bot" data-mood="idle" aria-hidden="true"><span class="eyes"><i></i><i></i></span></span></a>
43
43
  <span class="here" id="here"></span>
44
44
  <span class="grow"></span>
45
45
  <span id="mRight"></span>
@@ -47,12 +47,10 @@
47
47
  <div class="scrim" id="scrim"></div>
48
48
  <div class="shell">
49
49
  <aside class="side" id="side">
50
- <div class="brand"><a href="/" class="word" aria-label="nio">ni<span class="bot" data-mood="idle" aria-hidden="true"><span class="eyes"><i></i><i></i></span></span></a><button class="icon-btn collapse" id="collapse" type="button" aria-label="Plega el menú" aria-expanded="true"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="4.5" width="17" height="15" rx="2.5"/><path d="M9.5 4.5v15M15.5 10l-2 2 2 2"/></svg></button></div>
50
+ <div class="brand"><a href="/app" class="word" aria-label="nio">ni<span class="bot" data-mood="idle" aria-hidden="true"><span class="eyes"><i></i><i></i></span></span></a><button class="icon-btn collapse" id="collapse" type="button" aria-label="Plega el menú" aria-expanded="true"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="4.5" width="17" height="15" rx="2.5"/><path d="M9.5 4.5v15M15.5 10l-2 2 2 2"/></svg></button></div>
51
51
  <nav id="nav" aria-label="Seccions"></nav>
52
52
  <div class="foot">
53
53
  <div class="conn" id="sideConn"></div>
54
- <a class="kbd" href="#/ordinadors" data-act="help" title="Com es fa"><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><span class="l">Com es fa</span></a>
55
- <button class="kbd" data-act="tour" type="button" title="Tutorial"><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><span class="l">Tutorial</span></button>
56
54
  <button class="kbd" data-act="palette" type="button" title="Cerca o fes una acció (⌘K)"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg><span class="l">Cerca o fes una acció</span><kbd>⌘K</kbd></button>
57
55
  </div>
58
56
  </aside>
@@ -69,7 +67,7 @@
69
67
  <div class="tour-card" id="tourCard" role="dialog" aria-modal="false" aria-labelledby="tourT"></div>
70
68
  </div>
71
69
  <nav class="tabbar" id="tabbar" aria-label="Navegació"></nav>
72
- <div class="toasts" id="toasts" aria-live="polite"></div>
70
+ <div class="toasts" id="toasts" popover="manual" aria-live="polite"></div>
73
71
 
74
72
  <script src="/bot.js"></script>
75
73
  <script src="/board.js"></script>
package/web/app/style.css CHANGED
@@ -82,7 +82,12 @@ kbd { font: 600 11px var(--font); padding: 1px 6px; border: 1px solid var(--bord
82
82
  body.side-collapsed .shell { grid-template-columns: 72px minmax(0, 1fr); }
83
83
  body.side-collapsed .brand .collapse { transform: translateX(4px); }
84
84
  body.side-collapsed .brand .collapse svg { transform: scaleX(-1); }
85
- body.side-collapsed .brand .word, body.side-collapsed .side nav .grp { visibility: hidden; }
85
+ body.side-collapsed .brand .word { visibility: hidden; }
86
+ body.side-collapsed .side nav a .l, body.side-collapsed .kbd .l, body.side-collapsed .kbd kbd, body.side-collapsed .conn span, body.side-collapsed .conn svg { display: none; }
87
+ body.side-collapsed .conn { padding-left: 0; justify-content: center; }
88
+ body.side-collapsed .side nav .grp { font-size: 0; padding: 9px 13px 5px; }
89
+ body.side-collapsed .side nav .grp::before { content: ''; display: block; height: 1px; background: var(--border); }
90
+ body.side-collapsed .side nav .grp:first-child { display: none; }
86
91
  body.side-collapsed .side nav a .badge { position: absolute; top: 2px; right: 2px; min-width: 16px; height: 16px; font-size: 9px; padding: 0 4px; }
87
92
  }
88
93
 
@@ -454,7 +459,6 @@ video.scan { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 14p
454
459
  .tabbar { display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; padding: 6px 8px calc(6px + var(--safe-b)); background: color-mix(in srgb, var(--bg) 94%, transparent); -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); border-top: 1px solid var(--border); }
455
460
  .tabbar > * { flex: 1; position: relative; display: grid; justify-items: center; gap: 2px; padding: 6px 0; border: 0; background: none; border-radius: 10px; font-size: 11px; font-weight: 600; color: var(--fg-muted); text-decoration: none; }
456
461
  .tabbar [aria-current="page"] { color: var(--accent); }
457
- .tabbar [aria-current="page"]::before { content: ""; position: absolute; top: 0; left: 50%; width: 22px; height: 3px; margin-left: -11px; border-radius: 0 0 3px 3px; background: var(--accent); }
458
462
  .tabbar .badge { position: absolute; top: 2px; left: calc(50% + 6px); min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; background: var(--accent); color: #fff; font-size: 10px; line-height: 16px; text-align: center; }
459
463
  body:not(.no-tabs):not(.kb) .main { padding-bottom: calc(64px + var(--safe-b)); }
460
464
  body.no-tabs .tabbar, body.kb .tabbar { display: none; }
@@ -495,3 +499,67 @@ a.kbd { text-decoration: none; color: inherit; }
495
499
  .composer .mic:hover { color: var(--fg); border-color: var(--fg); }
496
500
  .composer .mic.rec { background: var(--red); border-color: var(--red); color: #fff; animation: pulse 1.1s ease-in-out infinite; }
497
501
  .composer .mic.wait { opacity: .5; }
502
+
503
+ /* ---------- Ordre i text net ---------- */
504
+ .grid2.one { grid-template-columns: minmax(0, 1fr); max-width: 640px; }
505
+ .top h1, .top p, .panel.empty h2, .panel.empty p, .stepper p, .stepper b, .help-body p, .card p, .sheet h2, .sheet p, .tour-card h3, .tour-card p, .muted.small { text-wrap: balance; }
506
+ @media (max-width: 820px) {
507
+ .side nav a[href="#/models"] { display: none; }
508
+ .top p, .panel.empty p, .stepper p, .help-body p, .sheet p, .tour-card p, .computer .muted { text-wrap: balance; }
509
+ .panel.empty { text-align: center; }
510
+ .panel.empty .stepper { text-align: left; }
511
+ }
512
+
513
+ /* ---------- Afegeix un ordinador ---------- */
514
+ dialog.sheet.wiz.compact { height: fit-content; max-height: min(86dvh, 720px); }
515
+ dialog.sheet.wiz.compact[open] { display: flex; flex-direction: column; }
516
+ .compact .wiz-body { min-height: 330px; padding-top: 12px; gap: 18px; }
517
+ .compact .wiz-head { gap: 14px; }
518
+ .compact .wiz-head .icon-btn { margin-right: -8px; }
519
+ .compact .wiz-head h2 { font-size: 22px; font-weight: 500; letter-spacing: -.045em; }
520
+ .compact .seg button svg { width: 16px; height: 16px; }
521
+ .compact .seg button[aria-pressed="true"] { box-shadow: 0 1px 2px color-mix(in srgb, var(--fg) 12%, transparent); }
522
+ .add-intro { display: grid; gap: 4px; }
523
+ .add-intro h3 { font-size: 18px; font-weight: 500; letter-spacing: -.03em; line-height: 1.25; }
524
+ .add-intro p, .hint { font-size: 14px; color: var(--fg-muted); text-wrap: pretty; }
525
+ .hint { font-size: 13px; }
526
+ .field-row { display: flex; gap: 8px; }
527
+ .field-row input { flex: 1; min-width: 0; height: 46px; }
528
+ .field-row .btn { height: 46px; padding: 0 18px; flex: none; }
529
+ .hosts { display: grid; gap: 8px; font-size: 12px; font-weight: 600; letter-spacing: .02em; color: var(--fg-muted); }
530
+ .hosts .row { gap: 6px; }
531
+ .todo { list-style: none; display: grid; gap: 14px; counter-reset: t; }
532
+ .todo li { display: grid; grid-template-columns: 24px 1fr; gap: 12px; align-items: start; font-size: 14px; }
533
+ .todo .n { width: 24px; height: 24px; display: grid; place-items: center; border-radius: 50%; background: var(--bg-2); font-size: 12px; font-weight: 600; margin-top: -1px; }
534
+ .todo li > div { display: grid; gap: 8px; min-width: 0; }
535
+ .todo .cmd { margin-top: 2px; }
536
+ .live { display: flex; align-items: center; gap: 10px; padding: 11px 14px; border-radius: 10px; background: var(--panel); border: 1px solid var(--border); font-size: 13px; color: var(--fg-muted); }
537
+ .live .pulse { width: 8px; height: 8px; flex: none; border-radius: 50%; background: var(--accent); animation: pulse 1.4s ease-in-out infinite; }
538
+ .checks { list-style: none; display: grid; gap: 8px; padding-top: 16px; border-top: 1px solid var(--border); font-size: 13px; color: var(--fg-muted); }
539
+ .checks li { display: flex; gap: 10px; align-items: baseline; }
540
+ .checks li::before { content: '✓'; color: var(--moss); font-weight: 700; }
541
+ .or { display: flex; align-items: center; gap: 12px; font-size: 12px; color: var(--fg-muted); }
542
+ .or::before, .or::after { content: ''; flex: 1; height: 1px; background: var(--border); }
543
+ .scan { width: 100%; border-radius: 12px; background: #000; aspect-ratio: 4 / 3; object-fit: cover; }
544
+ .sheet p { text-wrap: pretty; }
545
+ @media (max-width: 820px) {
546
+ .compact .wiz-body { min-height: 300px; }
547
+ .compact .seg button span { white-space: nowrap; }
548
+ }
549
+ dialog.sheet.wiz.compact .sheet-in, .compact .wiz-body { flex: 0 1 auto; }
550
+ .compact .wiz-body { overflow-y: auto; }
551
+
552
+ /* Alertes: capa superior (popover) i gest de lliscar */
553
+ .toasts[popover] { inset: auto 20px calc(20px + var(--safe-b)) auto; margin: 0; padding: 0; border: 0; background: none; color: inherit; overflow: visible; width: auto; height: auto; max-width: calc(100vw - 24px); display: none; }
554
+ .toasts[popover]:popover-open { display: grid; }
555
+ @media (max-width: 820px) { .toasts[popover] { inset: auto 12px calc(12px + var(--safe-b)) 12px; max-width: none; } body.chat-mode .toasts[popover] { bottom: calc(84px + var(--safe-b)); } }
556
+ .toast { touch-action: pan-y; user-select: none; -webkit-user-select: none; cursor: grab; box-shadow: 0 8px 24px color-mix(in srgb, var(--fg) 22%, transparent); }
557
+ .toast.out { opacity: 0; transform: translateY(8px); transition: opacity .2s, transform .2s; }
558
+
559
+ /* Bot de l'agent dins la pastilla: enganxat a l'esquerra, centrat en vertical */
560
+ .chip:has(.abot) { padding: 0 11px 0 3px; }
561
+ .chip .abot { line-height: 0; align-items: center; }
562
+ .chip .abot .bot { --s: 20px; --bw: .15; }
563
+ .chip .agent-tag { gap: 7px; }
564
+ .sheet .list + .or { margin-top: 18px; }
565
+ .sheet #modelForm { margin-top: 4px; }
package/web/board.css CHANGED
@@ -106,7 +106,7 @@
106
106
  @keyframes bd-msg { from { opacity: 0; transform: translateY(6px); } }
107
107
  .bd-chat .chips { display: flex; gap: 6px; overflow-x: auto; padding: 0 16px 10px; scrollbar-width: none; }
108
108
  .bd-chat .chips::-webkit-scrollbar { display: none; }
109
- .bd-chat .chip-s { flex: none; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 7px 11px; border: 1px solid var(--border); border-radius: 999px; background: var(--white); font-size: 12px; font-weight: 500; cursor: pointer; }
109
+ .bd-chat .chip-s { flex: none; max-width: 260px; box-sizing: border-box; height: 32px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 12px; line-height: 30px; text-align: center; border: 1px solid var(--border); border-radius: 999px; background: var(--white); font-size: 12px; font-weight: 500; cursor: pointer; }
110
110
  .bd-chat .chip-s:hover { border-color: var(--fg); }
111
111
  .bd-chat .cin { display: flex; gap: 8px; align-items: flex-end; padding: 12px 14px calc(14px + env(safe-area-inset-bottom)); border-top: 1px solid var(--border); }
112
112
  .bd-chat .cin textarea { flex: 1; min-height: 42px; max-height: 140px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 12px; background: var(--white); resize: none; font: inherit; font-size: 14px; line-height: 1.4; outline: none; }
@@ -132,7 +132,7 @@
132
132
  .bd-style { display: none; }
133
133
  .style-open .bd-style:not([hidden]) { display: grid; left: 10px; right: 10px; top: auto; bottom: calc(70px + env(safe-area-inset-bottom)); width: auto; grid-template-columns: 1fr 1fr; }
134
134
  .bd-help .cols { grid-template-columns: 1fr; }
135
- .bd-chat { position: absolute; left: 0; right: 0; bottom: 0; z-index: 9; width: auto; height: min(72%, 560px); border: 0; border-top: 1px solid var(--border); border-radius: 20px 20px 0 0; transform: translateY(105%); transition: transform .34s var(--bd-ease), visibility 0s linear .34s; box-shadow: 0 -20px 50px -30px color-mix(in srgb, var(--fg) 55%, transparent); }
135
+ .bd-chat { position: absolute; top: auto; left: 0; right: 0; bottom: 0; z-index: 9; width: auto; height: min(78%, 600px); border: 0; border-top: 1px solid var(--border); border-radius: 20px 20px 0 0; transform: translateY(105%); transition: transform .34s var(--bd-ease), visibility 0s linear .34s; box-shadow: 0 -20px 50px -30px color-mix(in srgb, var(--fg) 55%, transparent); }
136
136
  .chat-open .bd-chat { transform: none; transition: transform .34s var(--bd-ease), visibility 0s; }
137
137
  .bd-chat header::before { content: ""; position: absolute; top: 6px; left: 50%; width: 36px; height: 4px; margin-left: -18px; border-radius: 4px; background: var(--border); }
138
138
  .bd-empty { top: 42%; }
@@ -140,7 +140,7 @@
140
140
 
141
141
  /* El llenç s'adapta a la seva amplada real (tauleta, xat obert, mòbil) */
142
142
  @container stage (max-width: 720px) {
143
- .bd-chat { position: absolute; left: 0; right: 0; bottom: 0; z-index: 9; width: auto; height: min(72%, 560px); border: 0; border-top: 1px solid var(--border); border-radius: 20px 20px 0 0; transform: translateY(105%); transition: transform .34s var(--bd-ease), visibility 0s linear .34s; box-shadow: 0 -20px 50px -30px color-mix(in srgb, var(--fg) 55%, transparent); }
143
+ .bd-chat { position: absolute; top: auto; left: 0; right: 0; bottom: 0; z-index: 9; width: auto; height: min(78%, 600px); border: 0; border-top: 1px solid var(--border); border-radius: 20px 20px 0 0; transform: translateY(105%); transition: transform .34s var(--bd-ease), visibility 0s linear .34s; box-shadow: 0 -20px 50px -30px color-mix(in srgb, var(--fg) 55%, transparent); }
144
144
  .chat-open .bd-chat { transform: none; transition: transform .34s var(--bd-ease), visibility 0s; }
145
145
  .bd-chat header::before { content: ""; position: absolute; top: 6px; left: 50%; width: 36px; height: 4px; margin-left: -18px; border-radius: 4px; background: var(--border); }
146
146
  .chat-open .bd-tools, .chat-open .bd-right { transform: none; }
@@ -168,3 +168,24 @@
168
168
  .bd-tools .sep { display: none; }
169
169
  .bd-tools .tb { flex: 1 1 0; min-width: 30px; width: auto; height: 38px; }
170
170
  }
171
+
172
+ /* Xat com a drawer inferior (mòbil, tauleta i llenç estret) */
173
+ .bd-scrim { display: none; }
174
+ .bd-chat header { position: relative; touch-action: none; cursor: grab; }
175
+ .bd-chat.dragging { transition: none !important; }
176
+ @media (max-width: 820px) {
177
+ .bd-scrim { display: block; position: absolute; inset: 0; z-index: 8; border: 0; background: color-mix(in srgb, var(--fg) 30%, transparent); opacity: 0; visibility: hidden; transition: opacity .3s, visibility 0s linear .3s; }
178
+ .chat-open .bd-scrim { opacity: 1; visibility: visible; transition: opacity .3s, visibility 0s; }
179
+ .bd-chat { max-width: 640px; margin: 0 auto; }
180
+ .bd-chat header { padding-top: 22px; }
181
+ .bd-chat .msgs { overscroll-behavior: contain; }
182
+ .bd-chat .cin textarea { font-size: 16px; }
183
+ .bd-chat .cin { padding-bottom: calc(12px + env(safe-area-inset-bottom)); }
184
+ }
185
+ @container stage (max-width: 720px) {
186
+ .bd-scrim { display: block; position: absolute; inset: 0; z-index: 8; border: 0; background: color-mix(in srgb, var(--fg) 30%, transparent); opacity: 0; visibility: hidden; transition: opacity .3s, visibility 0s linear .3s; }
187
+ .chat-open .bd-scrim { opacity: 1; visibility: visible; transition: opacity .3s, visibility 0s; }
188
+ .bd-chat { max-width: 640px; margin: 0 auto; }
189
+ .bd-chat header { padding-top: 22px; }
190
+ .bd-chat .cin textarea { font-size: 16px; }
191
+ }
package/web/board.js CHANGED
@@ -1019,6 +1019,14 @@ function onKey(e) {
1019
1019
  }
1020
1020
  function onKeyUp(e) { if (e.key === ' ') { spaceDown = false; R && R.stage.classList.remove('space'); } }
1021
1021
 
1022
+ function chatSwipe(chat) {
1023
+ const head = chat.querySelector('header'); let y0 = null, dy = 0;
1024
+ const drawer = () => getComputedStyle(chat).bottom === '0px' && getComputedStyle(chat).left === '0px' && getComputedStyle(chat).top !== '0px';
1025
+ head.addEventListener('pointerdown', (e) => { if (e.target.closest('button') || !drawer()) return; y0 = e.clientY; dy = 0; head.setPointerCapture(e.pointerId); chat.classList.add('dragging'); });
1026
+ head.addEventListener('pointermove', (e) => { if (y0 === null) return; dy = Math.max(0, e.clientY - y0); chat.style.transform = `translateY(${dy}px)`; });
1027
+ const end = () => { if (y0 === null) return; y0 = null; chat.classList.remove('dragging'); chat.style.transform = ''; if (dy > 70) openChat(false); };
1028
+ head.addEventListener('pointerup', end); head.addEventListener('pointercancel', end);
1029
+ }
1022
1030
  function openChat(open, focus = true, persist = true) {
1023
1031
  if (persist) { B.opts.chat = open; save(); }
1024
1032
  if (!R) return;
@@ -1107,6 +1115,7 @@ function template() {
1107
1115
  </div>
1108
1116
  </div>
1109
1117
 
1118
+ <button type="button" class="bd-scrim" data-bd="chat" aria-label="Tanca el xat" tabindex="-1"></button>
1110
1119
  <aside class="bd-chat" id="bdChat" aria-label="Xat amb en Nio">
1111
1120
  <header><span class="who">${window.NioBot ? NioBot.html('idle', 'hbot') : ''}<span><b>Nio</b><small>Et dibuixa el que li demanis</small></span></span><button type="button" class="ib" data-bd="chat" aria-label="Tanca el xat">${svgI(ICON.close)}</button></header>
1112
1121
  <label class="brain"><span>Amb</span><select id="bdBrain" aria-label="Què fa servir el xat"></select></label>
@@ -1171,6 +1180,7 @@ function mount(root) {
1171
1180
  on(document, 'click', e => { if (R && !e.target.closest('.bd-menuwrap')) { const m = R.root.querySelector('#bdMenu'); if (m) m.hidden = true; } });
1172
1181
  on(window, 'resize', () => { applyView(); drawSel(); });
1173
1182
  if (document.fonts) document.fonts.load(`500 24px Caveat`).then(() => { if (R) { B.els.forEach(el => { if (el.type === 'text' && el.text) fitText(el); }); full(); } }).catch(() => {});
1183
+ chatSwipe(R.chat);
1174
1184
  full(); drawChat(); drawBrains(); setTool(tool); openChat(B.opts.chat && !matchMedia('(max-width: 820px)').matches, false, false);
1175
1185
  window.NioBot?.refresh();
1176
1186
  }
package/web/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // nio · service worker: obre l'app encara que no hi hagi connexió i mostra els avisos.
2
- const SHELL = 'nio-shell-v5';
2
+ const SHELL = 'nio-shell-v14';
3
3
  const FILES = ['/app', '/app/style.css', '/app/app.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
4
4
 
5
5
  self.addEventListener('install', (e) => {