nio-cat 0.4.4 → 0.5.1

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';
@@ -173,10 +187,6 @@ async function start() {
173
187
  if (ip) {
174
188
  publicUrl = publicUrl || `http://${ip}:${cfg.port}`;
175
189
  console.log(` ${c.dim('Xarxa:')} ${publicUrl} ${c.dim('(només la mateixa wifi)')}`);
176
- if (!flags.url) {
177
- // A la xarxa local, l'app es carrega des del mateix daemon (mateix origen).
178
- flags.app = publicUrl;
179
- }
180
190
  }
181
191
  }
182
192
 
@@ -188,6 +198,17 @@ async function start() {
188
198
  return showPair(publicUrl);
189
199
  }
190
200
 
201
+ if (fixed) {
202
+ const nt = startNamedTunnel(cfg.tunnelToken, () => {
203
+ publicUrl = cfg.fixedUrl;
204
+ console.log(` ${c.dim('Adreça fixa:')} ${publicUrl}`);
205
+ cfg.lastUrl = publicUrl;
206
+ config.save(cfg);
207
+ showPair(publicUrl);
208
+ });
209
+ if (nt) return console.log(c.dim(' Obrint el túnel fix…'));
210
+ }
211
+
191
212
  const t = startTunnel(cfg.port, (url) => {
192
213
  publicUrl = url;
193
214
  console.log(` ${c.dim('Túnel:')} ${url} ${c.dim('(Cloudflare, sense cost)')}`);
@@ -242,6 +263,8 @@ async function start() {
242
263
  --lan Connexió dins la mateixa wifi
243
264
  --url <adreça> Una adreça pública teva (Tailscale o similar)
244
265
  --no-tunnel Només des d'aquest ordinador
266
+ --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)
267
+ --forget-fixed Oblida l'adreça fixa i torna al túnel temporal
245
268
  --port <n> Port local (7717 si no dius res)
246
269
  --no-open No obris el panell al navegador d'aquest ordinador
247
270
  --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.1",
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",
@@ -77,7 +77,7 @@ function handle(ctx, line) {
77
77
  }
78
78
  case 'assistant':
79
79
  for (const b of ev.message?.content || []) {
80
- if (b.type === 'text' && b.text) ctx.endStream(b.text);
80
+ if (b.type === 'text' && b.text) { ctx.said = true; ctx.endStream(b.text); }
81
81
  else if (b.type === 'tool_use') ctx.tool({ id: b.id, name: b.name, input: b.input });
82
82
  }
83
83
  break;
@@ -93,6 +93,8 @@ function handle(ctx, line) {
93
93
  break;
94
94
  case 'result':
95
95
  if (ev.session_id) ctx.setNative(ev.session_id);
96
+ // Les comandes locals (/cost, /context…) no passen pel model: la resposta només és aquí.
97
+ if (!ctx.said && !ev.is_error && !ev.num_turns) ctx.endStream(typeof ev.result === 'string' && ev.result.trim() ? ev.result : 'Fet.');
96
98
  ctx.result({
97
99
  cost: ev.total_cost_usd,
98
100
  ms: ev.duration_ms,
package/src/commands.js CHANGED
@@ -23,8 +23,18 @@ function listDir(dir) {
23
23
  try { return fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
24
24
  }
25
25
 
26
+ // Comandes internes de Claude Code que també funcionen en mode headless (les altres, com /help o /status, no hi són).
27
+ const CLAUDE_BUILTIN = [
28
+ { name: 'usage', hint: 'Ús de la subscripció i límits' },
29
+ { name: 'context', hint: 'Mostra com s’omple el context' },
30
+ { name: 'compact', hint: 'Resumeix la conversa per alliberar context' },
31
+ { name: 'init', hint: 'Crea un CLAUDE.md al projecte' },
32
+ { name: 'review', hint: 'Revisa els canvis de codi' },
33
+ { name: 'security-review', hint: 'Revisió de seguretat dels canvis' },
34
+ ];
35
+
26
36
  function claudeCommands(cwd) {
27
- const out = new Map();
37
+ const out = new Map(CLAUDE_BUILTIN.map((c) => [c.name, { ...c, kind: 'comanda' }]));
28
38
  const bases = [path.join(os.homedir(), '.claude'), cwd && path.join(cwd, '.claude')].filter(Boolean);
29
39
  for (const base of bases) {
30
40
  // Comandes: commands/nom.md i commands/espai/nom.md (→ «espai:nom»)
@@ -45,6 +55,7 @@ function claudeCommands(cwd) {
45
55
  }
46
56
 
47
57
  function agentCommands(agent, cwd) {
58
+ if (agent === 'codex') return [{ name: 'status', hint: 'Compte, model i carpeta de la sessió', kind: 'comanda' }];
48
59
  return agent === 'claude' ? claudeCommands(cwd) : [];
49
60
  }
50
61
 
package/src/hub.js CHANGED
@@ -163,12 +163,38 @@ class Hub extends EventEmitter {
163
163
  this.emit('out', { type: 'removed', session: sid });
164
164
  }
165
165
 
166
+ // Codex no té /status en mode headless: ho resolem aquí amb el que sí que sap dir.
167
+ codexStatus(s, bp) {
168
+ const { execFile } = require('child_process');
169
+ execFile(bp, ['login', 'status'], { timeout: 8000 }, (err, out, errOut) => {
170
+ const rows = String(out || errOut || '').split('\n').map((l) => l.trim()).filter(Boolean), login = rows.find((l) => /logged|log in|login/i.test(l) && !/^warning/i.test(l)) || rows[0] || (err ? 'No he pogut llegir el compte' : '');
171
+ const lines = [`**Compte:** ${login}`, `**Model:** ${s.model || s.modelUsed || 'el de per defecte'}`, `**Carpeta:** \`${s.cwd}\``, `**Permisos:** ${s.mode}`, `**Conversa:** ${s.nativeId ? 'continua la de Codex' : 'encara no ha començat'}`];
172
+ this.push(s, 'text', { text: lines.join('\n\n') });
173
+ this.touch(s);
174
+ });
175
+ return this.summary(s);
176
+ }
177
+
166
178
  update(sid, patch) {
167
179
  const s = this.get(sid);
168
180
  if (!s) throw httpErr(404, 'Aquesta sessió no existeix');
169
181
  if (patch.mode && ['llegir', 'preguntar', 'tot'].includes(patch.mode)) s.mode = patch.mode;
170
182
  if (typeof patch.title === 'string') s.title = patch.title.slice(0, 120);
171
183
  if (typeof patch.model === 'string') s.model = patch.model.slice(0, 80);
184
+ if (typeof patch.cwd === 'string' && path.resolve(patch.cwd) !== s.cwd) {
185
+ const cwd = path.resolve(patch.cwd);
186
+ if (s.state === 'running' || s.state === 'waiting') throw httpErr(409, 'Espera que l’agent acabi o atura’l abans de canviar de carpeta');
187
+ let ok = false;
188
+ try { ok = fs.statSync(cwd).isDirectory(); } catch { /* no existeix */ }
189
+ if (!ok) throw httpErr(400, 'Aquesta carpeta no existeix');
190
+ // Claude Code desa cada conversa dins la carpeta del projecte: la copiem perquè l'agent la recordi.
191
+ if (s.agent === 'claude' && s.nativeId) {
192
+ const enc = (d) => path.join(require('os').homedir(), '.claude', 'projects', d.replace(/[^a-zA-Z0-9]/g, '-'), `${s.nativeId}.jsonl`);
193
+ try { fs.mkdirSync(path.dirname(enc(cwd)), { recursive: true }); fs.copyFileSync(enc(s.cwd), enc(cwd)); } catch { s.nativeId = null; }
194
+ }
195
+ s.cwd = cwd;
196
+ this.push(s, 'info', { text: `Carpeta canviada a ${cwd}` });
197
+ }
172
198
  this.touch(s);
173
199
  return this.summary(s);
174
200
  }
@@ -201,6 +227,7 @@ class Hub extends EventEmitter {
201
227
 
202
228
  if (!s.title) s.title = text.replace(/\s+/g, ' ').slice(0, 60);
203
229
  this.push(s, 'user', { text });
230
+ if (s.agent === 'codex' && /^\/status\s*$/i.test(text)) return this.codexStatus(s, bp);
204
231
  s.state = 'running';
205
232
  this.push(s, 'status', { state: 'running' });
206
233
 
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 */
@@ -507,7 +582,7 @@ function viewSessions() {
507
582
  const many = store.machines.length > 1, mach = machine();
508
583
  const switcher = many ? `<button class="chip switch-chip" type="button" data-act="machines"><i class="dot ${state.connected ? 'on' : ''}"></i>${esc(mach.name)}${svg(ICON.chev)}</button>` : '';
509
584
  const hint = mobileHint();
510
- const head = `<div class="top"><div><h1>Sessions</h1><p>Cada sessió és una conversa amb un agent, dins d’una carpeta.</p>${switcher}</div><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>${hint}`;
585
+ const head = `<div class="top"><div><h1>Sessions</h1><p>Cada sessió és una conversa amb un agent, dins d’una carpeta.</p>${switcher}</div><button class="btn ink new-fab" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>${hint}`;
511
586
  if (!state.connected && !list.length) {
512
587
  return `${head}<div class="panel empty">${bot(state.lastError ? 'sleep' : 'work')}<h2>${esc(state.lastError || 'Connectant amb l’ordinador…')}</h2>${state.lastError ? `<p>${mach.kind === 'vps' ? 'Potser el servidor s’ha reiniciat i l’adreça ha canviat.' : 'Comprova que nio continua engegat a l’ordinador i que el túnel està obert.'}</p><div class="row" style="justify-content:center"><button class="btn" data-act="retry" type="button">${svg(ICON.refresh)}Torna-ho a provar</button>${mach.kind === 'vps' && mach.via ? `<button class="btn ink" data-reconnect="${store.active}" type="button">Torna a connectar</button>` : ''}<a class="btn ghost" href="#/ordinadors">Ordinadors</a></div>` : ''}</div>`;
513
588
  }
@@ -605,7 +680,7 @@ function renderSessionHead() {
605
680
  const ag = state.info?.agents?.find((a) => a.id === s.agent);
606
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>
607
682
  ${ag?.noModes ? '' : `<button class="chip" data-act="mode" type="button">${esc(MODES[s.mode]?.label || s.mode)}</button>`}
608
- <span class="chip mono">${esc(basename(s.cwd))}</span>
683
+ <button class="chip mono" data-act="folder" type="button" title="${esc(s.cwd)} · canvia de carpeta">${esc(basename(s.cwd))}</button>
609
684
  <button class="chip mono" data-act="model" type="button" title="Canvia de model">${esc(s.model || 'Model per defecte')}</button>
610
685
  ${s.cost ? `<span class="chip mono">${s.cost.toFixed(2)} $</span>` : ''}
611
686
  ${stateChip(s)}`;
@@ -682,6 +757,7 @@ const NIO_CMDS = [
682
757
  { name: 'model', hint: 'Canvia de model' },
683
758
  { name: 'stop', hint: 'Atura l’agent' },
684
759
  { name: 'nova', hint: 'Nova sessió amb el mateix agent i carpeta' },
760
+ { name: 'carpeta', hint: 'Canvia la carpeta de la sessió' },
685
761
  { name: 'git', hint: 'Git i GitHub: commit i PR' },
686
762
  { name: 'nom', hint: 'Posa nom a la sessió', free: true },
687
763
  { name: 'opcions', hint: 'Opcions de la sessió' },
@@ -706,12 +782,33 @@ async function runCmd(s, name, arg) {
706
782
  case 'model': return arg ? patch({ model: arg }, `Model: ${arg}`) : modelSheet(s);
707
783
  case 'stop': try { await api(`/api/sessions/${s.id}/stop`, { method: 'POST' }); toast('Aturat'); } catch (e) { toast(e.message); } return;
708
784
  case 'nova': return newSessionSheet({ agent: s.agent, cwd: s.cwd, mode: s.mode });
785
+ case 'carpeta': return folderSheet(s);
709
786
  case 'git': return gitSheet(s.id);
710
787
  case 'nom': return arg ? patch({ title: arg }, 'Nom desat') : toast('Escriu el nom: /nom Arregla els tests');
711
788
  case 'opcions': return sessionOptions(s.id);
712
789
  case 'ajuda': return helpCmdsSheet(s);
713
790
  }
714
791
  }
792
+ function folderSheet(s) {
793
+ let d = null;
794
+ const draw = () => {
795
+ const crumbs = d ? d.path.split(/[\\/]/).filter(Boolean) : [];
796
+ $('#fsBody').innerHTML = !d ? '<p class="muted small">Carregant…</p>' : `<div class="fs-bar"><button class="icon-btn" type="button" data-fs="${esc(d.parent || '')}" ${d.parent ? '' : 'disabled'} aria-label="Carpeta de dalt">${svg(ICON.back)}</button><button class="chip" type="button" data-fs="${esc(d.home)}">Inici</button><span class="fs-path mono small" title="${esc(d.path)}">${esc(crumbs.slice(-2).join(' / ') || '/')}</span></div>
797
+ <div class="proj-list fs-list">${d.dirs.map((x) => `<button type="button" class="proj" data-fs="${esc(x.path)}"><span class="ic">›</span><span class="grow"><div ${x.hidden ? 'class="muted"' : ''}>${esc(x.name)}</div></span></button>`).join('') || '<p class="muted small">No hi ha subcarpetes. Pots triar aquesta.</p>'}</div>`;
798
+ const ok = $('#fsOk'); if (ok) { ok.disabled = !d || d.path === s.cwd; ok.textContent = d && d.path === s.cwd ? 'Ja hi és' : 'Treballa aquí'; }
799
+ };
800
+ const go = async (p) => { try { d = await api(`/api/fs?path=${encodeURIComponent(p || '')}`); } catch (e) { toast(e.message, 4200); } draw(); };
801
+ openSheet(`<h2>Canvia de carpeta</h2><p class="muted small">Ara: <span class="mono">${esc(s.cwd)}</span>. La conversa continua i l’agent la recorda.</p><div id="fsBody"></div><div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Cancel·la</button><button class="btn ink" id="fsOk" type="button" disabled>Treballa aquí</button></div>`, (sh) => {
802
+ sh.onclick = async (e) => {
803
+ if (e.target.closest('[data-close]')) return closeSheet();
804
+ const f = e.target.closest('[data-fs]'); if (f && !f.disabled) return go(f.dataset.fs);
805
+ if (e.target.closest('#fsOk') && d) {
806
+ try { const sum = await api(`/api/sessions/${s.id}`, { method: 'PATCH', body: { cwd: d.path } }); state.sessions.set(s.id, sum); closeSheet(); renderSessionHead(); cmdCache.delete(s.id); toast(`Carpeta: ${basename(sum.cwd)}`); } catch (er) { toast(er.message, 4200); }
807
+ }
808
+ };
809
+ draw(); go(s.cwd);
810
+ });
811
+ }
715
812
  function helpCmdsSheet(s) {
716
813
  const ac = agentCmds(s.id);
717
814
  openSheet(`<h2>Comandes</h2><p class="muted small">Escriu <span class="mono">/</span> al missatge per veure-les.</p><div class="list">${NIO_CMDS.map((c) => `<div class="card"><b class="mono">/${c.name}</b>${c.args ? ' <span class="muted small mono">l · p · t</span>' : ''}<div class="muted small">${esc(c.hint)}</div></div>`).join('')}</div>${ac.length ? `<p class="muted small">De ${esc(AGENT_NAMES[s.agent] || s.agent)}: ${ac.length} comandes i skills. Se li envien a l’agent tal qual.</p>` : ''}<div class="row sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button></div>`, (sh) => { $('[data-close]', sh).onclick = closeSheet; });
@@ -723,7 +820,7 @@ function slashItems(s, v) {
723
820
  if (!m) return [];
724
821
  const q = m[1].toLowerCase();
725
822
  const nio = NIO_CMDS.map((c) => ({ ...c, tag: 'nio' }));
726
- const ag = agentCmds(s.id).map((c) => ({ name: c.name, hint: c.hint, tag: AGENT_NAMES[s.agent] || s.agent, agent: true }));
823
+ const ag = agentCmds(s.id).map((c) => ({ name: c.name, hint: c.hint, tag: (AGENT_NAMES[s.agent] || s.agent).split(' ')[0], agent: true }));
727
824
  const all = [...nio, ...ag], starts = all.filter((c) => c.name.toLowerCase().startsWith(q)), has = all.filter((c) => !c.name.toLowerCase().startsWith(q) && c.name.toLowerCase().includes(q));
728
825
  return [...starts, ...has].slice(0, 40).map((c) => ({ ...c, label: `/${c.name}` }));
729
826
  }
@@ -734,7 +831,7 @@ function renderComposer() {
734
831
  if (box.dataset.busy === String(busy) && $('textarea', box)) return;
735
832
  box.dataset.busy = String(busy);
736
833
  if (s) loadCmds(s.id).then(() => { if (state.route.id === s.id) drawSlash(); });
737
- 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>`;
834
+ 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…'}" 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>`;
738
835
  const ta = $('#msg'); ta.value = prev;
739
836
  const grow = () => autogrow(ta);
740
837
  let items = [], sel = 0;
@@ -754,7 +851,8 @@ function renderComposer() {
754
851
  ta.value = ''; grow(); drawSlash(); runCmd(cur, c.name, '');
755
852
  };
756
853
  ta.addEventListener('input', () => { grow(); sel = 0; drawSlash(); });
757
- pop.addEventListener('pointerdown', (e) => { const b = e.target.closest('[data-i]'); if (b) { e.preventDefault(); pick(+b.dataset.i); } });
854
+ pop.addEventListener('mousedown', (e) => e.preventDefault());
855
+ pop.addEventListener('click', (e) => { const b = e.target.closest('[data-i]'); if (b) pick(+b.dataset.i); });
758
856
  const send = async () => {
759
857
  const text = ta.value.trim(); if (!text || !s) return;
760
858
  const c = text.match(/^\/([\w:.-]+)(?:\s+([\s\S]*))?$/), nio = c && NIO_CMDS.find((x) => x.name === c[1].toLowerCase());
@@ -778,8 +876,8 @@ function renderComposer() {
778
876
 
779
877
  /* ---------- Fulls */
780
878
  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;
879
+ 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}`;
880
+ if (!d.open) d.showModal(); raiseToasts(); if (T.on) $('#tour').hidden = true;
783
881
  onMount?.($('#sheetIn'));
784
882
  }
785
883
  const closeSheet = () => { const d = $('#sheet'); if (d.open) d.close(); };
@@ -817,7 +915,7 @@ async function newSessionSheet(preset = {}) {
817
915
  const body = () => { $('#wizBody', sh).innerHTML = VIEWS_[step](); after(); };
818
916
  const head = () => {
819
917
  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>
918
+ $('#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
919
  <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
920
  <p class="muted small">Pas ${step + 1} de ${STEPS.length} · ${STEPS[step]}</p>`;
823
921
  $('#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 +956,7 @@ async function newSessionSheet(preset = {}) {
858
956
  };
859
957
 
860
958
  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>`,
959
+ () => `<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
960
  () => {
863
961
  const crumbs = fsd ? fsd.path.split(/[\\/]/).filter(Boolean) : [];
864
962
  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 +1032,7 @@ async function modelSheet(s) {
934
1032
  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
1033
  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
1034
  <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) => {
1035
+ <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
1036
  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
1037
  $$('[data-model]', sh).forEach((b) => (b.onclick = () => save(b.dataset.model)));
940
1038
  $('#modelForm', sh).onsubmit = (e) => { e.preventDefault(); e.stopPropagation(); save($('#modelFree', sh).value.trim()); };
@@ -1112,8 +1210,10 @@ window.NioBrains = {
1112
1210
  }
1113
1211
  if (id === 'local') {
1114
1212
  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 || '';
1213
+ try {
1214
+ const r = await local.engine.chat.completions.create({ messages, temperature: 0.2 });
1215
+ return r.choices[0]?.message?.content || '';
1216
+ } 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
1217
  }
1118
1218
  throw new Error('Aquest model ja no està disponible');
1119
1219
  }
@@ -1175,6 +1275,7 @@ function bindLocal() {
1175
1275
  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
1276
  feed.scrollTop = feed.scrollHeight;
1177
1277
  const ta = $('#lmsg');
1278
+ if (local.draft) { ta.value = local.draft; local.draft = ''; autogrow(ta); }
1178
1279
  ta.addEventListener('input', () => autogrow(ta));
1179
1280
  ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !isTouch) { e.preventDefault(); sendLocal(); } });
1180
1281
  $('#lsend').onclick = () => (local.busy ? stopLocal() : sendLocal());
@@ -1193,6 +1294,15 @@ const LOCAL_SYSTEM = [
1193
1294
  'Si no saps una cosa, digues «no ho sé». No t’inventis dades, noms ni enllaços.',
1194
1295
  'No facis preguntes seguides ni allargassis la conversa. No facis servir emojis.'
1195
1296
  ].join(' ');
1297
+ const gpuLost = (e) => /mapAsync|GPUBuffer|GPUDevice|Instance reference|device (was )?lost|Device is lost|context lost/i.test(`${e?.message || e}`);
1298
+ // 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).
1299
+ async function recoverLocal(text) {
1300
+ const id = local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id);
1301
+ try { await local.engine?.unload?.(); } catch { /* ja és mort */ }
1302
+ local.engine = null; local.model = null; local.draft = text || '';
1303
+ toast('La GPU del mòbil s’ha reiniciat. Torno a carregar el model…', 5000);
1304
+ await loadLocal(id);
1305
+ }
1196
1306
  async function loadLocal(id) {
1197
1307
  local.loading = true; local.progress = 0; local.status = 'Preparant…'; render();
1198
1308
  try {
@@ -1225,9 +1335,13 @@ async function sendLocal() {
1225
1335
  acc += c.choices[0]?.delta?.content || ''; out.innerHTML = md(stripThink(acc)); if (nearBottom(feed)) feed.scrollTop = feed.scrollHeight;
1226
1336
  if (local.stopped) break;
1227
1337
  }
1228
- } catch (e) { if (!local.stopped) acc = acc || `Error: ${e.message}`; out.innerHTML = md(stripThink(acc)); }
1338
+ } catch (e) {
1339
+ if (gpuLost(e) && !acc) { local.messages.pop(); out.remove(); feed.lastElementChild?.remove(); local.busy = false; local.stopped = false; setLocalBusy(false); return recoverLocal(text); }
1340
+ if (!local.stopped) acc = acc || `Error: ${e.message}`; out.innerHTML = md(stripThink(acc));
1341
+ }
1229
1342
  out.classList.remove('cursor');
1230
1343
  if (local.stopped) { try { await local.engine.interruptGenerate(); } catch { /* res */ } }
1344
+ 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
1345
  local.messages.push({ role: 'assistant', content: stripThink(acc) || '…' }); local.busy = false; local.stopped = false; setLocalBusy(false);
1232
1346
  }
1233
1347
 
@@ -1286,7 +1400,7 @@ async function toggleMic() {
1286
1400
  rec.start(); $('#micBtn')?.classList.add('rec');
1287
1401
  }
1288
1402
  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>`;
1403
+ 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
1404
  }
1291
1405
  function voiceSheet() {
1292
1406
  const c = voiceCfg();
@@ -1305,7 +1419,7 @@ function voiceSheet() {
1305
1419
 
1306
1420
  /* ---------------------------------------------------------------- accions */
1307
1421
  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]');
1422
+ 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
1423
  if (!a) return;
1310
1424
  if (a.dataset.copy) {
1311
1425
  try { await navigator.clipboard.writeText(a.dataset.copy); } catch { return toast(a.dataset.copy); }
@@ -1320,7 +1434,8 @@ document.addEventListener('click', async (e) => {
1320
1434
  if (a.dataset.mic != null) return toggleMic();
1321
1435
  if (a.dataset.decide) return decide(a.closest('[data-approval]'), a.dataset.decide);
1322
1436
  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 }); }
1437
+ 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 }); }
1438
+ if (a.dataset.rename != null) return renameSheet(Number(a.dataset.rename));
1324
1439
  if (a.dataset.forget != null) { if (confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) forgetMachine(Number(a.dataset.forget)); return; }
1325
1440
  if (a.dataset.lm) { if (local.loading) return; LS.set('nio.localModel', a.dataset.lm); return render(); }
1326
1441
  switch (a.dataset.act) {
@@ -1339,8 +1454,9 @@ document.addEventListener('click', async (e) => {
1339
1454
  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
1455
  case 'add-computer': return addComputerSheet();
1341
1456
  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;
1457
+ case 'help': return go('#/ajuda');
1343
1458
  case 'more': return moreSheet();
1459
+ case 'folder': { const s = state.sessions.get(state.route.id); return s && folderSheet(s); }
1344
1460
  case 'machines': return machinesSheet();
1345
1461
  case 'mobile-qr': return mobileQrSheet();
1346
1462
  case 'mobile-hint-off': { const m = machine(); LS.set(`nio.mobHint.${m?.id || m?.url}`, true); return render(); }
@@ -1366,11 +1482,10 @@ document.addEventListener('submit', (e) => {
1366
1482
  /* ---------------------------------------------------------------- paleta d'ordres */
1367
1483
  let palSel = 0;
1368
1484
  function commands() {
1369
- const c = Object.entries(VIEWS).map(([k, v]) => ({ label: `Ves a ${v.label}`, hint: 'Navega', run: () => go(HASH[k]) }));
1485
+ 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
1486
  if (machine()) c.push({ label: 'Nova sessió', hint: 'Crea', run: newSessionSheet });
1371
1487
  c.push({ label: 'Afegeix un ordinador', hint: 'Crea', run: () => addComputerSheet() });
1372
1488
  [...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
1489
  c.push({ label: 'Configura la veu (ElevenLabs)', hint: 'Ajustos', run: voiceSheet });
1375
1490
  c.push({ label: 'Fes el tutorial', hint: 'Ajuda', run: tourStart });
1376
1491
  return c;
@@ -1429,12 +1544,12 @@ const TSTEPS = [
1429
1544
  { 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
1545
  { 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
1546
  { 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.' },
1547
+ { 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
1548
  { 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
1549
  { 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' }
1550
+ { 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.' },
1551
+ { 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í.') },
1552
+ { center: true, mood: 'happy', title: 'Ja està tot a punt.', text: 'Si mai vols repassar-ho, tens el tutorial a Ajuda.', next: 'Finalitza' }
1438
1553
  ];
1439
1554
  const tval = (v) => (typeof v === 'function' ? v() : v);
1440
1555
  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; }
@@ -478,13 +482,14 @@ a.kbd { text-decoration: none; color: inherit; }
478
482
  /* Comandes amb «/» */
479
483
  .composer { position: relative; }
480
484
  .slash { position: absolute; left: 24px; right: 24px; bottom: 100%; max-width: 760px; margin: 0 auto 6px; max-height: min(320px, 45dvh); overflow-y: auto; overscroll-behavior: contain; display: grid; padding: 6px; border: 1px solid var(--border); border-radius: 14px; background: var(--white); box-shadow: 0 10px 30px rgba(39, 34, 36, .12); z-index: 5; }
481
- .slash button { display: grid; grid-template-columns: auto 1fr auto; gap: 4px 12px; align-items: baseline; text-align: left; padding: 9px 10px; border: 0; border-radius: 9px; background: none; }
485
+ .slash button { display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr) auto; gap: 2px 12px; align-items: center; text-align: left; padding: 9px 10px; border: 0; border-radius: 9px; background: none; width: 100%; }
482
486
  .slash button[aria-selected="true"] { background: var(--bg-2); }
483
- .slash .n { font-weight: 600; }
487
+ .slash .n { font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
484
488
  .slash .h { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg-muted); font-size: 13px; }
485
- .slash .t { font-size: 11px; padding: 1px 7px; border-radius: 6px; background: var(--bg-2); color: var(--fg-muted); }
489
+ .slash .t { display: inline-flex; align-items: center; justify-content: center; height: 20px; padding: 0 8px; border-radius: 10px; background: var(--bg-2); color: var(--fg-muted); font-size: 11px; font-weight: 600; line-height: 1; white-space: nowrap; }
490
+ .slash button[aria-selected="true"] .t { background: var(--white); }
486
491
  .slash .t.ag { background: color-mix(in srgb, var(--lemon) 60%, var(--bg)); color: var(--fg); }
487
- @media (max-width: 820px) { .slash { left: 12px; right: 12px; } .slash .h { grid-column: 1 / -1; grid-row: 2; } }
492
+ @media (max-width: 820px) { .slash { left: 12px; right: 12px; } .slash button { grid-template-columns: minmax(0, 1fr) auto; } .slash .h { grid-column: 1 / -1; grid-row: 2; } .slash .t { grid-column: 2; grid-row: 1; } }
488
493
  /* ---------- Veu ---------- */
489
494
  .ev-error b { font-size: 12px; letter-spacing: .02em; text-transform: uppercase; }
490
495
  .speak { display: inline-flex; align-items: center; gap: 6px; margin-top: 8px; height: 28px; padding: 0 10px; border: 1px solid var(--border); border-radius: 999px; background: none; color: var(--fg-muted); font-size: 12px; font-weight: 600; }
@@ -495,3 +500,72 @@ a.kbd { text-decoration: none; color: inherit; }
495
500
  .composer .mic:hover { color: var(--fg); border-color: var(--fg); }
496
501
  .composer .mic.rec { background: var(--red); border-color: var(--red); color: #fff; animation: pulse 1.1s ease-in-out infinite; }
497
502
  .composer .mic.wait { opacity: .5; }
503
+
504
+ /* ---------- Ordre i text net ---------- */
505
+ .grid2.one { grid-template-columns: minmax(0, 1fr); max-width: 640px; }
506
+ .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; }
507
+ @media (max-width: 820px) {
508
+ .side nav a[href="#/models"] { display: none; }
509
+ .top p, .panel.empty p, .stepper p, .help-body p, .sheet p, .tour-card p, .computer .muted { text-wrap: balance; }
510
+ .panel.empty { text-align: center; }
511
+ .panel.empty .stepper { text-align: left; }
512
+ }
513
+
514
+ /* ---------- Afegeix un ordinador ---------- */
515
+ dialog.sheet.wiz.compact { height: fit-content; max-height: min(86dvh, 720px); }
516
+ dialog.sheet.wiz.compact[open] { display: flex; flex-direction: column; }
517
+ .compact .wiz-body { min-height: 330px; padding-top: 12px; gap: 18px; }
518
+ .compact .wiz-head { gap: 14px; }
519
+ .compact .wiz-head .icon-btn { margin-right: -8px; }
520
+ .compact .wiz-head h2 { font-size: 22px; font-weight: 500; letter-spacing: -.045em; }
521
+ .compact .seg button svg { width: 16px; height: 16px; }
522
+ .compact .seg button[aria-pressed="true"] { box-shadow: 0 1px 2px color-mix(in srgb, var(--fg) 12%, transparent); }
523
+ .add-intro { display: grid; gap: 4px; }
524
+ .add-intro h3 { font-size: 18px; font-weight: 500; letter-spacing: -.03em; line-height: 1.25; }
525
+ .add-intro p, .hint { font-size: 14px; color: var(--fg-muted); text-wrap: pretty; }
526
+ .hint { font-size: 13px; }
527
+ .field-row { display: flex; gap: 8px; }
528
+ .field-row input { flex: 1; min-width: 0; height: 46px; }
529
+ .field-row .btn { height: 46px; padding: 0 18px; flex: none; }
530
+ .hosts { display: grid; gap: 8px; font-size: 12px; font-weight: 600; letter-spacing: .02em; color: var(--fg-muted); }
531
+ .hosts .row { gap: 6px; }
532
+ .todo { list-style: none; display: grid; gap: 14px; counter-reset: t; }
533
+ .todo li { display: grid; grid-template-columns: 24px 1fr; gap: 12px; align-items: start; font-size: 14px; }
534
+ .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; }
535
+ .todo li > div { display: grid; gap: 8px; min-width: 0; }
536
+ .todo .cmd { margin-top: 2px; }
537
+ .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); }
538
+ .live .pulse { width: 8px; height: 8px; flex: none; border-radius: 50%; background: var(--accent); animation: pulse 1.4s ease-in-out infinite; }
539
+ .checks { list-style: none; display: grid; gap: 8px; padding-top: 16px; border-top: 1px solid var(--border); font-size: 13px; color: var(--fg-muted); }
540
+ .checks li { display: flex; gap: 10px; align-items: baseline; }
541
+ .checks li::before { content: '✓'; color: var(--moss); font-weight: 700; }
542
+ .or { display: flex; align-items: center; gap: 12px; font-size: 12px; color: var(--fg-muted); }
543
+ .or::before, .or::after { content: ''; flex: 1; height: 1px; background: var(--border); }
544
+ .scan { width: 100%; border-radius: 12px; background: #000; aspect-ratio: 4 / 3; object-fit: cover; }
545
+ .sheet p { text-wrap: pretty; }
546
+ @media (max-width: 820px) {
547
+ .compact .wiz-body { min-height: 300px; }
548
+ .compact .seg button span { white-space: nowrap; }
549
+ }
550
+ dialog.sheet.wiz.compact .sheet-in, .compact .wiz-body { flex: 0 1 auto; }
551
+ .compact .wiz-body { overflow-y: auto; }
552
+
553
+ /* Alertes: capa superior (popover) i gest de lliscar */
554
+ .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; }
555
+ .toasts[popover]:popover-open { display: grid; }
556
+ @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)); } }
557
+ .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); }
558
+ .toast.out { opacity: 0; transform: translateY(8px); transition: opacity .2s, transform .2s; }
559
+
560
+ /* Bot de l'agent dins la pastilla: enganxat a l'esquerra, centrat en vertical */
561
+ .chip:has(.abot) { padding: 0 11px 0 3px; }
562
+ .chip .abot { line-height: 0; align-items: center; }
563
+ .chip .abot .bot { --s: 20px; --bw: .15; }
564
+ .chip .agent-tag { gap: 7px; }
565
+ .sheet .list + .or { margin-top: 18px; }
566
+ .sheet #modelForm { margin-top: 4px; }
567
+
568
+ @media (max-width: 820px) {
569
+ .new-fab { position: fixed; right: 16px; bottom: calc(72px + var(--safe-b)); z-index: 25; height: 48px; padding: 0 18px 0 14px; border-radius: 16px; box-shadow: 0 14px 30px -14px color-mix(in srgb, var(--fg) 70%, transparent); }
570
+ body:not(.no-tabs):not(.kb) .main:has(.new-fab) { padding-bottom: calc(130px + var(--safe-b)); }
571
+ }
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-v15';
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) => {