nio-cat 0.2.0 → 0.3.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
@@ -4,13 +4,14 @@ const os = require('os');
4
4
  const path = require('path');
5
5
  const fs = require('fs');
6
6
  const crypto = require('crypto');
7
+ const net = require('net');
7
8
  const { spawn } = require('child_process');
8
9
  const config = require('../src/config');
9
10
  const { Hub } = require('../src/hub');
10
11
  const { createServer } = require('../src/server');
11
12
  const { detectAll } = require('../src/agents');
12
13
  const { startTunnel } = require('../src/tunnel');
13
- const { pairLink, terminalQr, writePairPage } = require('../src/pair');
14
+ const { pairLink, terminalQr, writePairPage, makeCode } = require('../src/pair');
14
15
  const { which } = require('../src/proc');
15
16
 
16
17
  const argv = process.argv.slice(2);
@@ -21,11 +22,11 @@ for (let i = 0; i < argv.length; i++) {
21
22
  if (a.startsWith('--')) {
22
23
  const [k, v] = a.slice(2).split('=');
23
24
  if (v !== undefined) flags[k] = v;
24
- else if (['port', 'url', 'app'].includes(k)) flags[k] = argv[++i];
25
+ else if (['port', 'url', 'app', 'local-port', 'dir'].includes(k)) flags[k] = argv[++i];
25
26
  else flags[k] = true;
26
27
  } else pos.push(a);
27
28
  }
28
- const cmd = ['start', 'pair', 'doctor', 'reset-token', 'help'].includes(pos[0]) ? pos.shift() : 'start';
29
+ const cmd = ['start', 'pair', 'doctor', 'reset-token', 'help', 'ssh'].includes(pos[0]) ? pos.shift() : 'start';
29
30
 
30
31
  const c = {
31
32
  dim: (s) => `\x1b[2m${s}\x1b[0m`,
@@ -80,13 +81,14 @@ function openDashboard() {
80
81
  else console.log(` ${c.dim('Panell:')} ${link}`);
81
82
  }
82
83
 
83
- function showPair(publicUrl) {
84
- const link = pairLink(flags.app || cfg.app, publicUrl, cfg.token);
85
- const file = writePairPage(link);
84
+ async function showPair(publicUrl) {
85
+ const link = pairLink(flags.app || cfg.app, publicUrl, cfg.token), code = makeCode(publicUrl, cfg.token);
86
+ const file = writePairPage(link, code);
86
87
  console.log(`\n ${c.b('Escaneja aquest codi amb el mòbil')} ${c.dim('(o obre ' + file + ')')}\n`);
87
88
  console.log(terminalQr(link));
88
- console.log(`\n ${c.dim('Enllaç:')} ${link}\n`);
89
- console.log(c.dim(' Aquest enllaç dona accés als teus agents. Guarda\'l com una contrasenya.\n'));
89
+ console.log(`\n ${c.b('Codi:')} ${code}`);
90
+ console.log(c.dim(' Copia\'l i enganxa\'l a Sessions → Tria un ordinador.'));
91
+ console.log(c.dim(' Aquest codi dona accés als teus agents. Guarda\'l com una contrasenya.\n'));
90
92
  if (flags.open) {
91
93
  const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
92
94
  try {
@@ -97,6 +99,75 @@ function showPair(publicUrl) {
97
99
  }
98
100
  }
99
101
 
102
+ // nio ssh usuari@servidor: engega nio al servidor (si no hi és), obre un túnel SSH i obre el panell ja connectat.
103
+ const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
104
+ function remoteScript(port, dir) {
105
+ return `set -e
106
+ command -v node >/dev/null 2>&1 || { echo NIO_NO_NODE; exit 0; }
107
+ up() { (exec 3<>/dev/tcp/127.0.0.1/${port}) 2>/dev/null; }
108
+ if ! up; then
109
+ mkdir -p "$HOME/.nio"
110
+ if command -v setsid >/dev/null 2>&1; then S=setsid; else S=; fi
111
+ $S nohup npx --yes nio-cat@latest --no-open --no-tunnel --port ${port} ${dir ? shq(dir) : '"$HOME"'} > "$HOME/.nio/daemon.log" 2>&1 < /dev/null &
112
+ for i in $(seq 1 80); do up && break; sleep 0.5; done
113
+ up || { echo NIO_NO_START; tail -n 5 "$HOME/.nio/daemon.log" 2>/dev/null; exit 0; }
114
+ fi
115
+ node -e "console.log('NIO_TOKEN='+require(require('os').homedir()+'/.nio/config.json').token)"
116
+ `;
117
+ }
118
+ function sshBase(target) {
119
+ const a = ['-o', 'ServerAliveInterval=30'];
120
+ if (process.platform !== 'win32') a.push('-o', 'ControlMaster=auto', '-o', 'ControlPath=/tmp/nio-%C', '-o', 'ControlPersist=10m');
121
+ return a;
122
+ }
123
+ function waitPort(port, ms = 15000) {
124
+ const end = Date.now() + ms;
125
+ return new Promise((resolve) => {
126
+ const t = () => {
127
+ const s = net.connect(port, '127.0.0.1');
128
+ s.once('connect', () => { s.destroy(); resolve(true); });
129
+ s.once('error', () => { s.destroy(); Date.now() > end ? resolve(false) : setTimeout(t, 300); });
130
+ };
131
+ t();
132
+ });
133
+ }
134
+ async function sshStart() {
135
+ const target = pos[0];
136
+ if (!target || !/^[A-Za-z0-9_][\w.@:\-\[\]]*$/.test(target)) {
137
+ console.log(`\n Ús: ${c.b('nio ssh usuari@servidor')} ${c.dim('[--port 7717] [--local-port 7718] [--dir /ruta/al/servidor]')}\n`);
138
+ process.exit(1);
139
+ }
140
+ const rport = Number(flags.port || 7717), lport = Number(flags['local-port'] || 7718);
141
+ console.log(`\n ${c.b('nio')} ${c.dim('· connectant amb')} ${target}${c.dim('…')}`);
142
+ const out = await new Promise((resolve) => {
143
+ const p = spawn('ssh', [...sshBase(target), '-T', '--', target, 'bash -l -s'], { stdio: ['pipe', 'pipe', 'inherit'] });
144
+ let buf = '';
145
+ p.stdout.on('data', (d) => (buf += d));
146
+ p.on('error', () => resolve({ code: -1, buf }));
147
+ p.on('close', (code) => resolve({ code, buf }));
148
+ p.stdin.end(remoteScript(rport, flags.dir));
149
+ });
150
+ if (out.code === -1) { console.error(c.warn('\n No trobo l’ordre ssh en aquest ordinador.\n')); process.exit(1); }
151
+ if (out.code !== 0 && !out.buf.includes('NIO_TOKEN=')) { console.error(c.warn('\n No he pogut entrar al servidor per SSH.\n')); process.exit(1); }
152
+ if (out.buf.includes('NIO_NO_NODE')) { console.error(c.warn('\n Al servidor falta Node.js 18 o més nou. Instal·la’l i torna-ho a provar.\n')); process.exit(1); }
153
+ if (out.buf.includes('NIO_NO_START')) { console.error(c.warn('\n nio no s’ha engegat al servidor:\n') + out.buf.replace('NIO_NO_START', '').trim() + '\n'); process.exit(1); }
154
+ const token = (out.buf.match(/NIO_TOKEN=(\S+)/) || [])[1];
155
+ if (!token) { console.error(c.warn('\n No he pogut llegir la clau del servidor.\n')); process.exit(1); }
156
+
157
+ const tun = spawn('ssh', [...sshBase(target), '-N', '-T', '-o', 'ExitOnForwardFailure=yes', '-L', `127.0.0.1:${lport}:127.0.0.1:${rport}`, '--', target], { stdio: ['ignore', 'ignore', 'inherit'] });
158
+ tun.on('close', () => { console.log(c.warn('\n S’ha tallat el túnel SSH.\n')); process.exit(0); });
159
+ const bye = () => { tun.kill(); process.exit(0); };
160
+ process.on('SIGINT', bye);
161
+ process.on('SIGTERM', bye);
162
+ if (!(await waitPort(lport))) { console.error(c.warn(`\n No puc obrir el port ${lport} en aquest ordinador. Prova amb --local-port 7719.\n`)); tun.kill(); process.exit(1); }
163
+
164
+ const local = `http://127.0.0.1:${lport}`, link = pairLink(local, local, token);
165
+ console.log(` ${c.ok('✓')} Connectat. nio funciona al servidor i el panell és a ${local}`);
166
+ if (canOpenBrowser() && openUrl(link)) console.log(` ${c.dim('Panell obert al navegador.')}`);
167
+ else console.log(` ${c.dim('Obre aquest enllaç:')} ${link}`);
168
+ console.log(c.dim('\n Deixa aquesta finestra oberta mentre facis servir el servidor. Amb Ctrl+C es tanca el túnel (nio continua al servidor).\n'));
169
+ }
170
+
100
171
  async function start() {
101
172
  if (flags.port) cfg.port = Number(flags.port);
102
173
  if (flags.app) cfg.app = flags.app;
@@ -178,6 +249,9 @@ async function start() {
178
249
 
179
250
  (async () => {
180
251
  switch (cmd) {
252
+ case 'ssh':
253
+ await sshStart();
254
+ break;
181
255
  case 'doctor':
182
256
  await doctor();
183
257
  break;
@@ -186,7 +260,7 @@ async function start() {
186
260
  console.log(' Encara no hi ha cap adreça desada. Engega nio primer.');
187
261
  break;
188
262
  }
189
- showPair(flags.url || cfg.lastUrl);
263
+ await showPair(flags.url || cfg.lastUrl);
190
264
  break;
191
265
  case 'reset-token':
192
266
  cfg.token = crypto.randomBytes(24).toString('base64url');
@@ -207,7 +281,12 @@ async function start() {
207
281
  --open Obre al navegador la pàgina del codi QR
208
282
  nio pair Mostra el codi QR una altra vegada
209
283
 
210
- Servidor (VPS): nio --url https://la-teva-adreça --no-open
284
+ nio ssh usuari@servidor Fes servir nio en un servidor (VPS) per SSH, sense obrir cap port.
285
+ --port <n> Port de nio al servidor (7717)
286
+ --local-port <n> Port en aquest ordinador (7718)
287
+ --dir <ruta> Carpeta del servidor on s'engega (per defecte, la teva carpeta d'inici)
288
+
289
+ Servidor amb adreça pública: nio --url https://la-teva-adreça --no-open
211
290
  Deixa'l sota tmux, pm2 o systemd perquè no s'aturi.
212
291
 
213
292
  nio doctor Llista els agents instal·lats
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
package/src/pair.js CHANGED
@@ -12,6 +12,18 @@ function pairLink(app, url, token) {
12
12
  return `${app.replace(/\/$/, '')}/app#u=${u}&t=${encodeURIComponent(token)}`;
13
13
  }
14
14
 
15
+ // Codi de connexió: l'adreça i la clau dins d'una sola cadena («nio-…»). No cal cap servidor.
16
+ // La web ja sap posar-hi l'enllaç de davant. Les adreces de Cloudflare es comprimeixen (≈ 60 caràcters).
17
+ const CF = '.trycloudflare.com';
18
+ function makeCode(url, token) {
19
+ const raw = Buffer.from(token, 'base64url');
20
+ const sub = url.startsWith('https://') && url.endsWith(CF) ? url.slice(8, -CF.length) : null;
21
+ const bytes = raw.length === 24 && raw.toString('base64url') === token && sub && /^[\w-]+$/.test(sub)
22
+ ? Buffer.concat([Buffer.from([1]), raw, Buffer.from(sub)])
23
+ : Buffer.concat([Buffer.from([0]), Buffer.from(`${url}\n${token}`)]);
24
+ return `nio-${bytes.toString('base64url')}`;
25
+ }
26
+
15
27
  function qr(text) {
16
28
  const q = qrcode(0, 'L');
17
29
  q.addData(text);
@@ -19,34 +31,49 @@ function qr(text) {
19
31
  return q;
20
32
  }
21
33
 
22
- // QR per al terminal: dues files de mòduls per línia amb mitjos blocs, i colors fixos
23
- // (negre sobre blanc) perquè la càmera el llegeixi igual amb tema clar o fosc.
34
+ // QR per al terminal: cada mòdul són dos espais amb color de fons (negre o blanc). No depèn de cap glif,
35
+ // així que no es talla amb tipus de lletra o interlineats estranys. Sempre negre sobre blanc, amb tema clar o fosc.
24
36
  function terminalQr(text, { color = !process.env.NO_COLOR } = {}) {
25
37
  const q = qr(text);
26
38
  const n = q.getModuleCount();
27
39
  const M = 2; // marge blanc al voltant, en mòduls
28
40
  const dark = (r, c) => r >= 0 && c >= 0 && r < n && c < n && q.isDark(r, c);
29
41
  const lines = [];
30
- for (let r = -M; r < n + M; r += 2) {
31
- let line = '';
42
+ for (let r = -M; r < n + M; r++) {
43
+ let line = '', prev = null;
32
44
  for (let c = -M; c < n + M; c++) {
33
- const top = dark(r, c);
34
- const bot = r + 1 < n + M && dark(r + 1, c);
35
- if (color) line += top && bot ? '█' : top ? '▀' : bot ? '▄' : ' ';
36
- else line += top && bot ? ' ' : top ? '▄' : bot ? '▀' : '█';
45
+ const d = dark(r, c);
46
+ if (color) {
47
+ if (d !== prev) line += d ? '\x1b[40m' : '\x1b[107m';
48
+ line += ' ';
49
+ } else line += d ? ' ' : '██';
50
+ prev = d;
37
51
  }
38
- lines.push(color ? ` \x1b[30;107m${line}\x1b[0m` : ` ${line}`);
52
+ lines.push(` ${line}${color ? '\x1b[0m' : ''}`);
39
53
  }
40
54
  return lines.join('\n');
41
55
  }
42
56
 
57
+ // SVG propi: una sola forma amb tires horitzontals de mòduls, vora de 4 mòduls i vores nítides.
58
+ // (El de la llibreria dibuixa cada mòdul per separat i, en escalar-lo, deixa línies fines entre mòduls.)
43
59
  function qrSvg(text) {
44
- return qr(text).createSvgTag({ cellSize: 6, margin: 4, scalable: true });
60
+ const q = qr(text), n = q.getModuleCount(), M = 4;
61
+ let d = '';
62
+ for (let r = 0; r < n; r++) {
63
+ for (let c = 0; c < n; c++) {
64
+ if (!q.isDark(r, c)) continue;
65
+ let w = 1;
66
+ while (c + w < n && q.isDark(r, c + w)) w++;
67
+ d += `M${c + M} ${r + M}h${w}v1h-${w}z`;
68
+ c += w - 1;
69
+ }
70
+ }
71
+ const size = n + M * 2;
72
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" shape-rendering="crispEdges" role="img" aria-label="Codi QR"><rect width="${size}" height="${size}" fill="#fff"/><path d="${d}" fill="#000"/></svg>`;
45
73
  }
46
74
 
47
- function writePairPage(link) {
48
- const q = qr(link);
49
- const svg = q.createSvgTag({ cellSize: 6, margin: 4, scalable: true });
75
+ function writePairPage(link, code) {
76
+ const svg = qrSvg(link);
50
77
  const html = `<!doctype html><html lang="ca"><head><meta charset="utf-8">
51
78
  <meta name="viewport" content="width=device-width,initial-scale=1"><title>nio // connecta el mòbil</title>
52
79
  <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600&display=swap" rel="stylesheet">
@@ -55,11 +82,10 @@ main{width:min(420px,100% - 40px);padding:40px 0}h1{font-size:30px;font-weight:5
55
82
  .qr{background:#fffefd;border:1px solid #cec7bd;padding:18px;border-radius:16px;margin:24px 0}.qr svg{width:100%;height:auto;display:block}
56
83
  code{display:block;word-break:break-all;font:12px/1.5 ui-monospace,Menlo,monospace;background:#e6e1d8;padding:12px 14px;border-radius:12px;color:#272224}small{display:block;margin-top:20px;font-size:13px;color:#69615c}</style></head>
57
84
  <body><main><h1>Connecta el mòbil</h1><p>Escaneja el codi amb la càmera. S’obrirà nio ja connectat a aquest ordinador.</p><div class="qr">${svg}</div>
58
- <p>Si fas servir nio instal·lat a la pantalla d’inici, copia aquest enllaç i enganxa’l a <b>Connecta un ordinador</b>:</p><code>${link.replace(/&/g, '&amp;')}</code>
59
- <small>Aquest enllaç dona accés als teus agents. Guarda’l com una contrasenya.</small></main></body></html>`;
85
+ <p>O copia aquest codi i enganxa'l a <b>Sessions → Tria un ordinador</b>:</p><code>${code}</code><small>Aquest codi dona accés als teus agents. Guarda'l com una contrasenya.</small></main></body></html>`;
60
86
  const file = path.join(HOME, 'pair.html');
61
87
  fs.writeFileSync(file, html, { mode: 0o600 });
62
88
  return file;
63
89
  }
64
90
 
65
- module.exports = { pairLink, terminalQr, writePairPage, qrSvg };
91
+ module.exports = { pairLink, terminalQr, writePairPage, qrSvg, makeCode };
package/src/server.js CHANGED
@@ -10,7 +10,7 @@ const ollama = require('./agents/ollama');
10
10
  const { save } = require('./config');
11
11
  const { httpErr } = require('./hub');
12
12
  const github = require('./github');
13
- const { pairLink, qrSvg } = require('./pair');
13
+ const { pairLink, qrSvg, makeCode } = require('./pair');
14
14
 
15
15
  // L'app web: copiada dins del paquet en publicar-lo, o l'arrel del repositori en desenvolupament.
16
16
  const WEB_DIRS = [path.join(__dirname, '..', 'web'), path.join(__dirname, '..', '..')];
@@ -108,9 +108,32 @@ function listProjects(cfg) {
108
108
  return out;
109
109
  }
110
110
 
111
- function insideRoots(cfg, p) {
112
- const r = path.resolve(p);
113
- return cfg.roots.some((root) => r === root || r.startsWith(root + path.sep));
111
+ // Qualsevol carpeta existent d'aquest ordinador. Qui té la clau ja pot engegar agents, així que no la limitem a les compartides.
112
+ function isDir(p) {
113
+ try {
114
+ return fs.statSync(path.resolve(p)).isDirectory();
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ // Subcarpetes d'una carpeta, per a l'explorador de «Nova sessió».
121
+ function listDir(cfg, raw) {
122
+ const dir = path.resolve(raw || cfg.roots[0] || os.homedir());
123
+ if (!isDir(dir)) throw httpErr(400, 'Aquesta carpeta no existeix');
124
+ let entries = [];
125
+ try {
126
+ entries = fs.readdirSync(dir, { withFileTypes: true });
127
+ } catch {
128
+ throw httpErr(403, 'No tinc permís per veure aquesta carpeta');
129
+ }
130
+ const dirs = entries
131
+ .filter((e) => (e.isDirectory() || (e.isSymbolicLink() && isDir(path.join(dir, e.name)))) && e.name !== 'node_modules')
132
+ .map((e) => ({ name: e.name, path: path.join(dir, e.name), hidden: e.name.startsWith('.') }))
133
+ .sort((a, b) => a.hidden - b.hidden || a.name.localeCompare(b.name, 'ca'))
134
+ .slice(0, 500);
135
+ const parent = path.dirname(dir);
136
+ return { path: dir, parent: parent === dir ? null : parent, home: os.homedir(), git: fs.existsSync(path.join(dir, '.git')), dirs };
114
137
  }
115
138
 
116
139
  // Noms de model d'Ollama: «qwen3:4b», «hf.co/usuari/model:Q4_K_M»…
@@ -149,6 +172,7 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
149
172
  };
150
173
  }
151
174
  if (p === '/api/projects' && method === 'GET') return listProjects(cfg);
175
+ if (p === '/api/fs' && method === 'GET') return listDir(cfg, url.searchParams.get('path'));
152
176
  if (p === '/api/ollama/models' && method === 'GET') return (await ollama.listModels()) || [];
153
177
  if (p === '/api/ollama' && method === 'GET') {
154
178
  const models = await ollama.listModels();
@@ -208,7 +232,7 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
208
232
  const pub = getPublicUrl();
209
233
  if (!pub || /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/i.test(pub)) return { link: null };
210
234
  const link = pairLink(getApp ? getApp() : cfg.app, pub, cfg.token);
211
- return { link, svg: qrSvg(link), url: pub, lan: /^http:\/\//.test(pub) };
235
+ return { link, code: makeCode(pub, cfg.token), svg: qrSvg(link), url: pub, lan: /^http:\/\//.test(pub) };
212
236
  }
213
237
  if (p === '/api/github' && method === 'GET') return github.status();
214
238
  if (p === '/api/github/repos' && method === 'GET') return github.repos(url.searchParams.get('owner') || '');
@@ -222,8 +246,7 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
222
246
  if (p === '/api/sessions' && method === 'POST') {
223
247
  const b = await readBody(req);
224
248
  const cwd = path.resolve(b.cwd || cfg.roots[0]);
225
- if (!insideRoots(cfg, cwd)) throw httpErr(403, 'Aquesta carpeta no és de les que has compartit en engegar nio');
226
- if (!fs.existsSync(cwd)) throw httpErr(400, 'Aquesta carpeta no existeix');
249
+ if (!isDir(cwd)) throw httpErr(400, 'Aquesta carpeta no existeix');
227
250
  const s = hub.create({ agent: b.agent, cwd, title: b.title, mode: b.mode, model: b.model });
228
251
  if (b.text) hub.send(s.id, b.text, hookUrl());
229
252
  return hub.summary(s);
@@ -245,7 +268,6 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
245
268
  if ((r = m(/^\/api\/sessions\/([\w-]+)\/git(?:\/(commit|pr))?$/))) {
246
269
  const s = hub.get(r[1]);
247
270
  if (!s) throw httpErr(404, 'Aquesta sessió no existeix');
248
- if (!insideRoots(cfg, s.cwd)) throw httpErr(403, 'Aquesta carpeta no és de les compartides');
249
271
  if (!r[2] && method === 'GET') return github.gitInfo(s.cwd);
250
272
  if (r[2] === 'commit' && method === 'POST') return github.commit(s.cwd, await readBody(req));
251
273
  if (r[2] === 'pr' && method === 'POST') return github.pr(s.cwd, await readBody(req));
package/web/app/app.js CHANGED
@@ -36,7 +36,6 @@ const MODES = {
36
36
  preguntar: { label: 'Demana permís', short: 'Permís' },
37
37
  tot: { label: 'Via lliure', short: 'Lliure' }
38
38
  };
39
- const DEMO_URL = 'demo:navegador';
40
39
 
41
40
  /* ---------------------------------------------------------------- estat */
42
41
  const state = { route: { name: 'sessions' }, ollama: null, info: null, connected: false, lastError: null, sessions: new Map(), approvals: new Map(), events: new Map(), loaded: new Set() };
@@ -45,13 +44,12 @@ const LS = {
45
44
  set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* emmagatzematge bloquejat */ } }
46
45
  };
47
46
  const store = {
48
- get machines() { return LS.get('nio.machines', []); },
47
+ get machines() { return LS.get('nio.machines', []).filter((m) => m.url !== 'demo:navegador'); },
49
48
  set machines(v) { LS.set('nio.machines', v); },
50
49
  get active() { return Number(LS.get('nio.active', 0)) || 0; },
51
50
  set active(i) { LS.set('nio.active', i); }
52
51
  };
53
52
  const machine = () => store.machines[store.active] || store.machines[0] || null;
54
- const isDemo = () => machine()?.url === DEMO_URL;
55
53
 
56
54
  /* ---------------------------------------------------------------- utilitats */
57
55
  function toast(msg, ms = 2800) {
@@ -102,174 +100,23 @@ function prettyInput(input) {
102
100
  return JSON.stringify(input, null, 2);
103
101
  }
104
102
 
105
- /* ---------------------------------------------------------------- ordinador de prova (dins del navegador) */
106
- const demo = (() => {
107
- const listeners = new Set();
108
- const sessions = new Map(), approvals = new Map(), notices = [];
109
- const runs = new Map();
110
- const omodels = [{ name: 'qwen3:4b', size: 2.5e9, params: '4.0B', quant: 'Q4_K_M' }], opulls = new Map();
111
- const emit = (msg) => listeners.forEach((f) => f(JSON.parse(JSON.stringify(msg))));
112
- const err = (status, message) => Object.assign(new Error(message), { status });
113
- const summary = (s) => {
114
- const last = [...s.events].reverse().find((e) => e.kind === 'text' || e.kind === 'user');
115
- return { id: s.id, agent: s.agent, title: s.title, cwd: s.cwd, mode: s.mode, model: s.model, state: s.state, created: s.created, updated: s.updated, cost: 0, last: last ? last.text.slice(0, 160) : '', pending: [...approvals.values()].filter((a) => a.session === s.id && a.state === 'pending').length };
116
- };
117
- const touch = (s) => { s.updated = Date.now(); emit({ type: 'session', session: summary(s) }); };
118
- const push = (s, kind, data = {}) => {
119
- const lastEv = s.events[s.events.length - 1];
120
- if (lastEv && lastEv.open) delete lastEv.open;
121
- const ev = { seq: ++s.seq, t: Date.now(), kind, ...data }; s.events.push(ev);
122
- emit({ type: 'event', session: s.id, event: ev }); touch(s); return ev;
123
- };
124
- const chunk = (s, text) => {
125
- const last = s.events[s.events.length - 1];
126
- if (last && last.kind === 'text' && last.open) { last.text += text; emit({ type: 'event', session: s.id, event: last }); return; }
127
- const ev = push(s, 'text', { text }); ev.open = true;
128
- };
129
- const pub = (a) => { const { resolve, ...r } = a; return r; };
130
- function ask(s, tool, input) {
131
- const a = { id: rid(), session: s.id, agent: s.agent, title: s.title, tool, input, state: 'pending', created: Date.now() };
132
- const p = new Promise((res) => (a.resolve = res));
133
- approvals.set(a.id, a); s.state = 'waiting';
134
- push(s, 'approval', { approval: a.id, tool, input, state: 'pending' });
135
- emit({ type: 'approval', approval: pub(a) });
136
- notices.unshift({ id: rid(4), t: Date.now(), title: 'Demo et demana permís', body: `${tool}: ${input.command || ''}`, session: s.id });
137
- return p;
138
- }
139
- function decide(id, decision) {
140
- const a = approvals.get(id); if (!a || a.state !== 'pending') return null;
141
- a.state = decision === 'allow' ? 'allowed' : 'denied';
142
- const s = sessions.get(a.session);
143
- if (s) { const ev = s.events.find((e) => e.kind === 'approval' && e.approval === id); if (ev) { ev.state = a.state; emit({ type: 'event', session: s.id, event: ev }); } s.state = runs.has(s.id) ? 'running' : 'idle'; touch(s); }
144
- emit({ type: 'approval', approval: pub(a) }); a.resolve(a.state === 'allowed' ? 'allow' : 'deny');
145
- return pub(a);
146
- }
147
- async function run(s, prompt) {
148
- let stopped = false; runs.set(s.id, { stop: () => (stopped = true) });
149
- if (s.agent === 'ollama') {
150
- try {
151
- for (const w of `(Resposta simulada de ${s.model || 'qwen3:4b'}.) Amb Ollama de debò, aquí veuries la resposta del model que funciona al teu ordinador, paraula per paraula.`.split(' ')) { if (stopped) throw 0; chunk(s, w + ' '); await sleep(45); }
152
- push(s, 'result', { ms: 1800 });
153
- } catch { push(s, 'info', { text: 'Has aturat l’agent' }); }
154
- runs.delete(s.id); s.state = 'idle'; push(s, 'status', { state: 'idle', code: 0 }); return;
155
- }
156
- try {
157
- for (const w of `Entesos. Faig una ullada al projecte per a «${prompt.slice(0, 80)}».`.split(' ')) { if (stopped) throw 0; chunk(s, w + ' '); await sleep(55); }
158
- push(s, 'tool', { id: 't1', name: 'Read', input: { file_path: 'README.md' } }); await sleep(450);
159
- push(s, 'tool_result', { id: 't1', name: 'Read', output: '# Projecte de prova\nAixò és un README d’exemple.' });
160
- if (stopped) throw 0;
161
- const input = { command: 'npm test' };
162
- if (s.mode === 'llegir') push(s, 'text', { text: 'Treballo en mode només lectura, així que no executo res. El següent pas seria `npm test`.' });
163
- else {
164
- const ok = s.mode === 'tot' ? true : (await ask(s, 'Bash', input)) === 'allow';
165
- if (stopped) throw 0;
166
- push(s, 'tool', { id: 't2', name: 'Bash', input }); await sleep(380);
167
- if (ok) { push(s, 'tool_result', { id: 't2', name: 'Bash', output: '✓ 12 tests passen' }); push(s, 'text', { text: 'Fet. Tots els tests passen.' }); }
168
- else { push(s, 'tool_result', { id: 't2', name: 'Bash', output: 'Rebutjat des de nio', error: true }); push(s, 'text', { text: 'D’acord, no l’executo. Què vols que faci?' }); }
169
- }
170
- push(s, 'result', { cost: 0, ms: 2400 });
171
- runs.delete(s.id); s.state = 'idle'; push(s, 'status', { state: 'idle', code: 0 });
172
- } catch {
173
- runs.delete(s.id); s.state = 'idle'; push(s, 'info', { text: 'Has aturat l’agent' }); push(s, 'status', { state: 'idle', code: 130 });
174
- }
175
- }
176
- const info = () => ({ name: 'nio', version: 'prova', host: 'Ordinador de prova', platform: 'navegador', user: 'tu', publicUrl: DEMO_URL, roots: ['~/projectes'], vapidKey: null,
177
- agents: [
178
- { id: 'demo', name: 'Demo', available: true, version: 'simulat', approvals: true, login: 'No cal instal·lar res' },
179
- { id: 'claude', name: 'Claude Code', available: false, approvals: true, login: 'Instal·la nio al teu ordinador' },
180
- { id: 'codex', name: 'Codex', available: false, login: 'Instal·la nio al teu ordinador' },
181
- { id: 'grok', name: 'Grok', available: false, beta: true, login: 'Instal·la nio al teu ordinador' },
182
- { id: 'antigravity', name: 'Antigravity', available: false, beta: true, login: 'Instal·la nio al teu ordinador' }
183
- ] });
184
- async function request(method, path, body) {
185
- await sleep(60);
186
- const url = new URL(path, 'http://x'), p = url.pathname; let m;
187
- if (p === '/api/info') return info();
188
- if (p === '/api/projects') return [{ path: '~/projectes', name: 'projectes', root: true }, { path: '~/projectes/botiga', name: 'botiga', git: true }, { path: '~/projectes/web', name: 'web', git: true }];
189
- if (p === '/api/ollama/models') return omodels;
190
- if (p === '/api/ollama/chat') {
191
- // Sense model de veritat: proposa un mapa amb les paraules clau de la petició.
192
- const ask = String(body.messages?.at(-1)?.content || '').split('Petició de l’usuari:').pop().trim();
193
- const words = [...new Set(ask.replace(/[^\p{L}\p{N}\s-]/gu, ' ').split(/\s+/).filter((w) => w.length > 3))].slice(0, 7);
194
- await sleep(700);
195
- return { content: JSON.stringify({ reply: 'Model simulat: amb Ollama de debò entendria la petició sencera. T’he fet un esquema amb les paraules clau.', actions: words.length > 1 ? [{ op: 'mindmap', center: words[0], items: words.slice(1) }] : [] }) };
196
- }
197
- if (p === '/api/github') return { available: false, authed: false };
198
- if (/^\/api\/sessions\/[\w-]+\/git$/.test(p)) return { repo: false };
199
- if (p === '/api/pair') return { link: null };
200
- if (p === '/api/ollama') return { available: false, host: 'simulat', models: [], running: [], pulling: [] };
201
- if (p === '/api/ollama/pull' && method === 'POST') {
202
- const name = String(body.name || '').trim(); if (!/^[\w][\w.\-/]{0,100}(:[\w.\-]{1,60})?$/.test(name)) throw err(400, 'Aquest nom de model no és vàlid');
203
- if (opulls.has(name) || omodels.some((m) => m.name === name)) return { ok: true, already: true };
204
- const total = 1.2e9 + Math.random() * 3e9, job = { name, status: 'pulling manifest', completed: 0, total };
205
- job.t = setInterval(() => {
206
- job.completed = Math.min(total, job.completed + total / 18); job.status = job.completed < total ? 'pulling' : 'success';
207
- emit({ type: 'ollama', name, status: job.status, completed: job.completed, total });
208
- if (job.completed >= total) { clearInterval(job.t); opulls.delete(name); omodels.push({ name, size: total, params: '', quant: 'Q4_K_M' }); emit({ type: 'ollama', name, done: true }); }
209
- }, 260);
210
- opulls.set(name, job); return { ok: true };
211
- }
212
- if ((m = p.match(/^\/api\/ollama\/pull\/(.+)$/)) && method === 'DELETE') { const j = opulls.get(decodeURIComponent(m[1])); if (j) { clearInterval(j.t); opulls.delete(j.name); emit({ type: 'ollama', name: j.name, done: true, error: 'Descàrrega cancel·lada' }); } return { ok: !!j }; }
213
- if ((m = p.match(/^\/api\/ollama\/models\/(.+)$/)) && method === 'DELETE') { const n = decodeURIComponent(m[1]); const i = omodels.findIndex((x) => x.name === n); if (i >= 0) omodels.splice(i, 1); return { ok: true }; }
214
- if (p === '/api/sessions' && method === 'GET') return [...sessions.values()].sort((a, b) => b.updated - a.updated).map(summary);
215
- if (p === '/api/sessions' && method === 'POST') {
216
- if (body.agent !== 'demo' && body.agent !== 'ollama') throw err(400, 'A l’ordinador de prova només hi ha els agents Demo i Ollama');
217
- const s = { id: rid(), agent: body.agent, cwd: body.cwd || '~/projectes', title: (body.text || '').replace(/\s+/g, ' ').slice(0, 60), mode: body.mode || 'preguntar', model: body.model || '', state: 'idle', created: Date.now(), updated: Date.now(), seq: 0, events: [] };
218
- sessions.set(s.id, s); touch(s);
219
- if (body.text) send(s.id, body.text);
220
- return summary(s);
221
- }
222
- if ((m = p.match(/^\/api\/sessions\/([\w-]+)$/))) {
223
- const s = sessions.get(m[1]); if (!s) throw err(404, 'Aquesta sessió no existeix');
224
- if (method === 'GET') return { session: summary(s), events: s.events.filter((e) => e.seq > Number(url.searchParams.get('since') || 0)) };
225
- if (method === 'PATCH') { if (body.mode) s.mode = body.mode; if (typeof body.title === 'string') s.title = body.title.slice(0, 120); touch(s); return summary(s); }
226
- if (method === 'DELETE') { runs.get(s.id)?.stop(); sessions.delete(s.id); emit({ type: 'removed', session: s.id }); return { ok: true }; }
227
- }
228
- if ((m = p.match(/^\/api\/sessions\/([\w-]+)\/message$/))) return send(m[1], body.text);
229
- if ((m = p.match(/^\/api\/sessions\/([\w-]+)\/stop$/))) { const r = runs.get(m[1]); r?.stop(); return { stopped: !!r }; }
230
- if (p === '/api/approvals') return [...approvals.values()].filter((a) => a.state === 'pending').map(pub);
231
- if ((m = p.match(/^\/api\/approvals\/([\w-]+)$/))) { const a = decide(m[1], body.decision); if (!a) throw err(404, 'Aquesta petició ja s’ha resolt'); return a; }
232
- if (p === '/api/notices') return notices.slice(0, 5);
233
- if (p.startsWith('/api/push')) throw err(400, 'Els avisos només funcionen amb un ordinador de veritat');
234
- throw err(404, 'Aquesta ruta no existeix');
235
- }
236
- function send(sid, text) {
237
- const s = sessions.get(sid); if (!s) throw err(404, 'Aquesta sessió no existeix');
238
- if (runs.has(sid)) throw err(409, 'L’agent encara està treballant. Espera que acabi o atura’l.');
239
- text = String(text || '').trim(); if (!text) throw err(400, 'El missatge és buit');
240
- if (!s.title) s.title = text.slice(0, 60);
241
- push(s, 'user', { text }); s.state = 'running'; push(s, 'status', { state: 'running' });
242
- run(s, text); return summary(s);
243
- }
244
- function subscribe(fn) {
245
- listeners.add(fn);
246
- fn({ type: 'hello', sessions: [...sessions.values()].map(summary), approvals: [...approvals.values()].filter((a) => a.state === 'pending').map(pub) });
247
- return () => listeners.delete(fn);
248
- }
249
- return { request, subscribe };
250
- })();
251
-
252
103
  /* ---------------------------------------------------------------- API */
253
104
  async function api(path, opts = {}) {
254
105
  const m = machine();
255
106
  if (!m) throw new Error('No hi ha cap ordinador connectat');
256
107
  const body = opts.body && typeof opts.body !== 'string' ? opts.body : opts.body ? JSON.parse(opts.body) : undefined;
257
- if (m.url === DEMO_URL) return demo.request(opts.method || 'GET', path, body || {});
258
108
  const res = await fetch(m.url + path, { ...opts, headers: { Authorization: `Bearer ${m.token}`, 'content-type': 'application/json', ...(opts.headers || {}) }, body: body ? JSON.stringify(body) : undefined });
259
109
  const data = await res.json().catch(() => ({}));
260
110
  if (!res.ok) throw new Error(data.error || `Error ${res.status}`);
111
+ if (Array.isArray(data.agents)) data.agents = data.agents.filter((x) => x.id !== 'demo'); // l'agent de proves del programa no s'ensenya
261
112
  return data;
262
113
  }
263
114
 
264
- let streamCtrl = null, unsubDemo = null, retry = 0;
115
+ let streamCtrl = null, retry = 0;
265
116
  async function connect() {
266
- streamCtrl?.abort(); unsubDemo?.(); unsubDemo = null;
117
+ streamCtrl?.abort();
267
118
  const m = machine();
268
119
  if (!m) { state.connected = false; return render(); }
269
- if (m.url === DEMO_URL) {
270
- state.info = await api('/api/info'); state.connected = true; state.lastError = null;
271
- unsubDemo = demo.subscribe(handle); render(); brainsForBoard(); return;
272
- }
273
120
  const ctrl = new AbortController(); streamCtrl = ctrl;
274
121
  try {
275
122
  state.info = await api('/api/info');
@@ -344,14 +191,26 @@ function parsePair(text) {
344
191
  if (!url || !token) return null;
345
192
  return { url: url.replace(/\/$/, ''), token };
346
193
  }
194
+ // Codi «nio-…»: porta l'adreça i la clau dins (vegeu makeCode al programa). Es descodifica aquí, sense servidor.
195
+ const CF = '.trycloudflare.com';
196
+ function parseCode(text) {
197
+ const m = String(text || '').replace(/\s+/g, '').match(/^nio-([\w-]+)$/i); if (!m) return null;
198
+ try {
199
+ const bin = atob(m[1].replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (m[1].length % 4)) % 4)), u = Uint8Array.from(bin, (c) => c.charCodeAt(0));
200
+ if (u[0] === 1) return { url: `https://${new TextDecoder().decode(u.slice(25))}${CF}`, token: btoa(String.fromCharCode(...u.slice(1, 25))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') };
201
+ if (u[0] === 0) { const [url, token] = new TextDecoder().decode(u.slice(1)).split('\n'); if (url && token) return { url: url.replace(/\/$/, ''), token }; }
202
+ } catch { /* codi malmès */ }
203
+ return null;
204
+ }
205
+ // Un codi o un enllaç de connexió (#u=…&t=…).
206
+ const pairFrom = (text) => parsePair(text) || parseCode(text);
347
207
  function addMachine({ url, token, name }) {
348
208
  const ms = store.machines; let i = ms.findIndex((m) => m.url === url);
349
- if (i >= 0) ms[i].token = token; else { ms.push({ url, token, name: name || (url === DEMO_URL ? 'Ordinador de prova' : new URL(url).hostname) }); i = ms.length - 1; }
209
+ if (i >= 0) ms[i].token = token; else { ms.push({ url, token, name: name || new URL(url).hostname }); i = ms.length - 1; }
350
210
  store.machines = ms; store.active = i;
351
211
  state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.info = null; state.ollama = null;
352
212
  connect();
353
213
  }
354
- function tryDemo() { addMachine({ url: DEMO_URL, token: 'demo', name: 'Ordinador de prova' }); toast('Connectat a l’ordinador de prova'); go('#/sessions'); }
355
214
  function checkPairHash() {
356
215
  const pair = parsePair(location.hash); if (!pair) return false;
357
216
  history.replaceState(null, '', location.pathname + '#/sessions');
@@ -368,6 +227,11 @@ const VIEWS = {
368
227
  mobil: { label: 'Xat local', icon: ICON.phone, group: 'Eines' },
369
228
  pissarra: { label: 'Pissarra', icon: ICON.board, group: 'Eines' }
370
229
  };
230
+ const NOMACHINE = {
231
+ approvals: { title: 'Aprovacions', lead: 'Quan un agent vol editar un fitxer o executar una ordre, t’ho demana aquí.' },
232
+ models: { title: 'Models', lead: 'Models d’Ollama que funcionen a l’ordinador, sense internet i sense comptes.' },
233
+ maquina: { title: 'Ordinador', lead: 'Els agents instal·lats, els avisos al mòbil i els ordinadors connectats.' }
234
+ };
371
235
  const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', mobil: '#/mobil', models: '#/models', maquina: '#/ordinador', pissarra: '#/pissarra' };
372
236
  function parseRoute() {
373
237
  const h = location.hash.replace(/^#\/?/, '');
@@ -417,7 +281,7 @@ function render() {
417
281
  else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
418
282
  else {
419
283
  const html = r.name === 'mobil' ? viewLocal() : null;
420
- view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html : !machine() && r.name !== 'maquina' ? viewConnect() : r.name === 'sessions' ? viewSessions() : r.name === 'approvals' ? viewApprovals() : r.name === 'models' ? viewModels() : viewMachine()}</div>`;
284
+ view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html : r.name === 'sessions' ? (machine() ? viewSessions() : viewConnect()) : !machine() ? viewNoMachine(NOMACHINE[r.name]) : r.name === 'approvals' ? viewApprovals() : r.name === 'models' ? viewModels() : viewMachine()}</div>`;
421
285
  if (r.name === 'mobil') bindLocal();
422
286
  if (r.name === 'models' && machine()) loadOllama();
423
287
  }
@@ -427,29 +291,46 @@ function render() {
427
291
  }
428
292
 
429
293
  /* ---------- Connecta */
430
- function viewConnect(compact = false) {
431
- const CMD = 'npx nio-cat';
432
- const main = `<div class="panel conn-main">
433
- <ol class="stepper">
434
- <li><span class="n">1</span><div><b>Engega nio</b><p>Al terminal, des de la carpeta dels teus projectes.</p></div></li>
435
- <li><span class="n">2</span><div><b>Escaneja el QR</b><p>Amb la càmera del mòbil. S’obrirà nio connectat.</p></div></li>
436
- <li><span class="n">3</span><div><b>Dona-li feina</b><p>Tria un agent i una carpeta, i explica què vols.</p></div></li>
437
- </ol>
438
- <div class="cmd big"><code>${CMD}</code><button class="btn sm" data-copy="${CMD}" type="button">${svg(ICON.copy)}Copia</button></div>
439
- <div class="reqs"><span class="chip">Node.js 18 o més nou</span><span class="chip ghost">cloudflared · opcional</span></div>
440
- <p class="muted small">Sense <span class="mono">cloudflared</span> et connectes per la wifi de casa. Amb ell, des de qualsevol lloc (a macOS: <span class="mono">brew install cloudflared</span>).</p>
441
- <details class="paste" ${compact ? 'open' : ''}><summary>Ja tens l’enllaç del terminal?</summary>
442
- <form class="pair-form" id="pairForm"><input type="url" id="pairInput" placeholder="https://www.nio.cat/app#u=…&t=…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Enllaç de connexió"><button class="btn ink" type="submit">Connecta</button></form>
443
- <p class="muted small">El trobaràs sota el codi QR. Va bé si fas servir nio des de la pantalla d’inici.</p>
444
- </details>
445
- </div>`;
446
- if (compact) return main;
294
+ // La connexió viu només a Sessions. Les altres pàgines, sense ordinador, hi envien.
295
+ const isPhone = () => isTouch || matchMedia('(max-width: 820px)').matches;
296
+ function viewNoMachine(what) {
297
+ return `<div class="top"><div><h1>${esc(what.title)}</h1><p>${esc(what.lead)}</p></div></div>
298
+ <div class="panel empty">${bot('sleep')}<h2>Sembla que no hi ha cap sessió iniciada</h2><p>Crea’n una de nova a la pàgina Sessions. Allà connectes l’ordinador i hi dones feina.</p><a class="btn ink" href="#/sessions">Ves a Sessions</a></div>`;
299
+ }
300
+ function pasteForm(open) {
301
+ return `<details class="paste" ${open ? 'open' : ''}><summary>Tens un codi?</summary>
302
+ <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>
303
+ <p class="muted small">Surt al terminal, sota el codi QR. Copia’l sencer.</p></details>`;
304
+ }
305
+ function viewConnect() {
306
+ const CMD = 'npx nio-cat', cmd = (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>`;
307
+ if (isPhone()) {
308
+ return `<section class="connect">
309
+ <div class="connect-head"><h1>Tria un ordinador</h1><p class="lead">Els agents treballen al teu ordinador, amb els teus comptes i els teus fitxers. Des del mòbil els dones feina i aproves el que facin.</p></div>
310
+ <div class="panel conn-main">
311
+ <ol class="stepper">
312
+ <li><span class="n">1</span><div><b>Engega nio a l’ordinador</b><p>Al terminal, des de la carpeta dels teus projectes.</p></div></li>
313
+ <li><span class="n">2</span><div><b>Escaneja el QR</b><p>Amb la càmera del mòbil. S’obrirà nio connectat.</p></div></li>
314
+ </ol>
315
+ ${cmd(CMD)}
316
+ <p class="muted small">Per connectar-t’hi des de qualsevol xarxa cal <span class="mono">cloudflared</span>. Sense ell, el mòbil ha d’estar a la mateixa wifi.</p>
317
+ ${pasteForm(false)}
318
+ </div>
319
+ </section>`;
320
+ }
447
321
  return `<section class="connect">
448
- <div class="connect-head"><h1>Connecta el teu ordinador</h1><p class="lead">Els agents treballen al teu ordinador, amb els teus comptes i els teus fitxers. Des d’aquí els dones feina, els veus treballar i aproves el que facin.</p></div>
449
- <div class="connect-grid">
450
- ${main}
451
- <aside class="panel try">${bot('happy')}<h2>Prova-ho sense instal·lar res</h2><p class="muted small">Un ordinador simulat dins del navegador, amb un agent de demostració que llegeix, et demana permís i executa.</p><button class="btn ink block" data-act="demo" type="button">Obre l’ordinador simulat</button><a class="btn ghost block" href="#/mobil">O fes servir el xat local</a></aside>
322
+ <div class="connect-head"><h1>Tria un ordinador</h1><p class="lead">Els agents treballen a l’ordinador, amb els teus comptes i els teus fitxers. Des d’aquí els dones feina, els veus treballar i aproves el que facin.</p></div>
323
+ <div class="choice-grid conn-main">
324
+ <section class="panel choice"><header><h2>Aquest ordinador</h2></header><div class="body stack">
325
+ <p class="small">Al terminal, des de la carpeta dels teus projectes:</p>${cmd(CMD)}
326
+ <p class="muted small">S’obre aquest panell ja connectat. Cal Node.js 18 o més nou.</p></div></section>
327
+ <section class="panel choice"><header><h2>Un altre ordinador</h2></header><div class="body stack">
328
+ <p class="small">Engega nio a l’altre ordinador o a un servidor. Copia aquí el codi que mostra.</p>
329
+ ${pasteForm(true)}
330
+ <p class="small"><b>Un servidor (VPS) per SSH.</b> Des d'aquest ordinador, sense obrir cap port:</p>${cmd('npx nio-cat ssh usuari@servidor')}
331
+ <p class="muted small">Engega nio al servidor, obre el túnel i el panell queda connectat.</p></div></section>
452
332
  </div>
333
+ <p class="muted small" style="margin-top:14px">Després, al mòbil, tria l’ordinador i escaneja el codi QR que mostra el panell.</p>
453
334
  </section>`;
454
335
  }
455
336
 
@@ -648,8 +529,8 @@ function renderComposer() {
648
529
  }
649
530
 
650
531
  /* ---------- Fulls */
651
- function openSheet(html, onMount) {
652
- const d = $('#sheet'); $('#sheetIn').innerHTML = `<div class="grab"></div>${html}`;
532
+ function openSheet(html, onMount, cls) {
533
+ 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}`;
653
534
  if (!d.open) d.showModal(); if (T.on) $('#tour').hidden = true;
654
535
  onMount?.($('#sheetIn'));
655
536
  }
@@ -664,85 +545,122 @@ function modeHelp(agentId, mode) {
664
545
  if (agentId === 'codex') return 'Pot editar dins la carpeta, sense accés a internet. Codex no pot fer preguntes en aquest mode.';
665
546
  return 'Aquest agent encara no pot demanar permís: les accions que el necessitarien es rebutgen.';
666
547
  }
548
+ // Assistent de 4 passos: agent, carpeta exacta, opcions i tasca. Capçalera i peu fixos; només el cos fa scroll.
667
549
  async function newSessionSheet(preset = {}) {
668
550
  if (!machine()) return go('#/sessions');
669
551
  if (!state.connected) return toast('Encara no hi ha connexió amb l’ordinador');
670
552
  const agents = [...(state.info?.agents || [])].sort((x, y) => Number(!!y.available) - Number(!!x.available));
671
553
  let projects = [];
672
554
  try { projects = await api('/api/projects'); } catch (e) { return toast(e.message); }
673
- const first = (isDemo() && agents.find((a) => a.id === 'demo')) || agents.find((a) => a.available && a.id !== 'demo') || agents.find((a) => a.available);
674
- const sel = { agent: first?.id || 'demo', cwd: projects.find((p) => !p.root)?.path || projects[0]?.path, mode: 'preguntar', model: '', ...preset };
675
- openSheet(`<h2>Nova sessió</h2>
676
- <div class="stack"><div class="section-title" style="margin:0">Agent</div><div class="picker" id="agentPick"></div></div>
677
- <div class="stack"><div class="row between"><div class="section-title" style="margin:0">Carpeta</div><button class="chip" type="button" id="ghOpen">Clona de GitHub</button></div><div class="proj-list" id="projPick"></div><div class="stack" id="ghWrap" hidden></div></div>
678
- <div class="stack" id="modeWrap"><div class="section-title" style="margin:0">Permisos</div><div class="seg" id="modePick" role="group">${Object.entries(MODES).map(([k, m]) => `<button type="button" data-mode="${k}">${m.label}</button>`).join('')}</div><p class="muted small" id="modeHelp"></p></div>
679
- <div id="modelWrap"></div>
680
- <label class="f"><span>Què ha de fer?</span><textarea class="field" id="firstMsg" rows="3" placeholder="Per exemple: troba per què fallen els tests i arregla-ho"></textarea></label>
681
- <div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Cancel·la</button><button class="btn ink" id="startBtn" type="button">Comença</button></div>`, (sh) => {
682
- const paint = () => {
683
- $('#agentPick', sh).innerHTML = 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('');
684
- $('#projPick', sh).innerHTML = projects.map((p) => `<button type="button" class="proj" data-path="${esc(p.path)}" aria-pressed="${p.path === sel.cwd}"><span class="ic">${p.git ? 'git' : '·'}</span><span class="grow"><div>${esc(p.name)}</div><div class="p">${esc(p.path)}</div></span></button>`).join('') || '<p class="muted small">No hi ha carpetes compartides.</p>';
685
- const ag = agents.find((a) => a.id === sel.agent);
686
- $('#modeWrap', sh).hidden = !!ag?.noModes;
687
- $$('#modePick button', sh).forEach((b) => b.setAttribute('aria-pressed', b.dataset.mode === sel.mode));
688
- $('#modeHelp', sh).textContent = modeHelp(sel.agent, sel.mode);
555
+ const first = agents.find((a) => a.available);
556
+ const sel = { agent: first?.id || '', cwd: projects.find((p) => !p.root)?.path || projects[0]?.path || '', mode: 'preguntar', model: '', text: '', ...preset };
557
+ const STEPS = ['Agent', 'Carpeta', 'Opcions', 'Tasca'];
558
+ let step = preset.agent ? 1 : 0, fsd = null, gh = null, models = null;
559
+ const agentOf = () => agents.find((a) => a.id === sel.agent);
560
+
561
+ openSheet('', (sh) => {
562
+ const body = () => { $('#wizBody', sh).innerHTML = VIEWS_[step](); after(); };
563
+ const head = () => {
564
+ const last = step === STEPS.length - 1;
565
+ $('#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>
566
+ <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>
567
+ <p class="muted small">Pas ${step + 1} de ${STEPS.length} · ${STEPS[step]}</p>`;
568
+ $('#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>`;
689
569
  };
690
- const paintModel = async () => {
691
- const w = $('#modelWrap', sh);
692
- if (sel.agent === 'ollama') {
693
- const models = await api('/api/ollama/models').catch(() => []);
694
- if (!sel.model) sel.model = models[0]?.name || '';
695
- w.innerHTML = `<label class="f"><span>Model</span><select id="modelSel">${models.map((m) => `<option ${m.name === sel.model ? 'selected' : ''}>${esc(m.name)}</option>`).join('') || '<option value="">Cap model (ollama pull qwen3:4b)</option>'}</select></label>`;
696
- $('#modelSel', sh).onchange = (e) => (sel.model = e.target.value);
697
- } else if (sel.agent === 'demo') w.innerHTML = '';
698
- else {
699
- w.innerHTML = '<label class="f"><span>Model <span class="muted">(opcional)</span></span><input type="text" id="modelIn" placeholder="El que l’agent faci servir per defecte" autocapitalize="off" spellcheck="false"></label>';
700
- $('#modelIn', sh).value = sel.model; $('#modelIn', sh).oninput = (e) => (sel.model = e.target.value.trim());
701
- }
570
+ const draw = () => {
571
+ if (!$('#wizBody', sh)) sh.innerHTML = '<div class="wiz-head" id="wizHead"></div><div class="wiz-body" id="wizBody"></div><div class="wiz-foot" id="wizFoot"></div>';
572
+ head(); body();
573
+ $('#wizBody', sh).scrollTop = 0;
702
574
  };
703
- let ghRepos = null;
704
- const ghPaint = (q = '') => {
705
- const w = $('#ghList', sh); if (!w) return;
706
- const qq = q.trim().toLowerCase(), list = (ghRepos || []).filter((r) => !qq || r.repo.toLowerCase().includes(qq) || r.description.toLowerCase().includes(qq)).slice(0, 40);
707
- w.innerHTML = list.map((r) => `<button type="button" class="proj" data-repo="${esc(r.repo)}"><span class="ic">git</span><span class="grow"><div>${esc(r.repo)}${r.private ? ' <span class="chip beta">privat</span>' : ''}</div>${r.description ? `<div class="p">${esc(r.description)}</div>` : ''}</span></button>`).join('') || '<p class="muted small">No hi ha cap repositori amb aquest nom.</p>';
575
+ const after = () => {
576
+ if (step === 1 && !fsd) fsGo(sel.cwd);
577
+ if (step === 2 && sel.agent === 'ollama' && models == null) api('/api/ollama/models').catch(() => []).then((m) => { models = m; if (!sel.model) sel.model = m[0]?.name || ''; if (step === 2) body(); });
578
+ if (step === 3 && !isTouch) setTimeout(() => $('#firstMsg', sh)?.focus(), 60);
708
579
  };
709
- const ghOpen = async () => {
710
- const w = $('#ghWrap', sh); if (!w.hidden) { w.hidden = true; return; }
711
- w.hidden = false; w.innerHTML = '<p class="muted small">Connectant amb GitHub…</p>';
712
- let st; try { st = await api('/api/github'); } catch (e) { w.innerHTML = `<p class="muted small">${esc(e.message)}</p>`; return; }
713
- if (!st.available) { w.innerHTML = '<p class="muted small">No trobo l’eina <span class="mono">gh</span> en aquest ordinador. Instal·la-la des de <a href="https://cli.github.com" target="_blank" rel="noopener">cli.github.com</a> i inicia sessió amb <span class="mono">gh auth login</span>.</p>'; return; }
714
- if (!st.authed) { w.innerHTML = '<p class="muted small">Falta iniciar sessió a GitHub. Al terminal de l’ordinador, executa <span class="mono">gh auth login</span>.</p>'; return; }
715
- w.innerHTML = `<input type="search" id="ghQ" placeholder="Cerca entre els teus repositoris${st.user ? ' (' + esc(st.user) + ')' : ''}" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Cerca un repositori"><div class="proj-list" id="ghList"><p class="muted small">Carregant repositoris…</p></div>`;
716
- $('#ghQ', sh).oninput = (e) => ghPaint(e.target.value);
717
- try { ghRepos = await api('/api/github/repos'); ghPaint(); } catch (e) { $('#ghList', sh).innerHTML = `<p class="muted small">${esc(e.message)}</p>`; }
580
+
581
+ /* --- carpeta: la carpeta oberta és la triada */
582
+ const fsGo = async (dir) => {
583
+ try { fsd = await api(`/api/fs?path=${encodeURIComponent(dir || '')}`); sel.cwd = fsd.path; gh = null; }
584
+ catch (e) { toast(e.message, 4200); if (!fsd) fsd = { path: sel.cwd, parent: null, home: '', dirs: [] }; }
585
+ if (step === 1) body();
586
+ };
587
+ const ghList = () => {
588
+ const q = (gh.q || '').trim().toLowerCase(), list = (gh.repos || []).filter((r) => !q || r.repo.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)).slice(0, 40);
589
+ return list.map((r) => `<button type="button" class="proj" data-repo="${esc(r.repo)}"><span class="ic">git</span><span class="grow"><div>${esc(r.repo)}${r.private ? ' <span class="chip beta">privat</span>' : ''}</div>${r.description ? `<div class="p">${esc(r.description)}</div>` : ''}</span></button>`).join('') || '<p class="muted small">No hi ha cap repositori amb aquest nom.</p>';
590
+ };
591
+ const ghStart = async () => {
592
+ gh = { msg: 'Connectant amb GitHub…', repos: null, q: '' }; body();
593
+ let st; try { st = await api('/api/github'); } catch (e) { gh.msg = esc(e.message); return body(); }
594
+ if (!st.available) gh.msg = 'No trobo l’eina <span class="mono">gh</span> en aquest ordinador. Instal·la-la des de <a href="https://cli.github.com" target="_blank" rel="noopener">cli.github.com</a> i inicia sessió amb <span class="mono">gh auth login</span>.';
595
+ else if (!st.authed) gh.msg = 'Falta iniciar sessió a GitHub. Al terminal de l’ordinador, executa <span class="mono">gh auth login</span>.';
596
+ else { gh.user = st.user; gh.msg = 'Carregant repositoris…'; body(); try { gh.repos = await api('/api/github/repos'); gh.msg = ''; } catch (e) { gh.msg = esc(e.message); } }
597
+ if (gh && step === 1) body();
718
598
  };
719
- $('#ghOpen', sh).onclick = ghOpen;
720
599
  const ghClone = async (repo, btn) => {
721
600
  btn.disabled = true; btn.querySelector('.p, div')?.insertAdjacentHTML('beforeend', ' <span class="muted">· clonant…</span>');
722
- try {
723
- const r = await api('/api/github/clone', { method: 'POST', body: { repo } });
724
- if (!projects.some((p) => p.path === r.path)) projects.unshift({ path: r.path, name: r.path.split(/[\\/]/).pop(), root: false, git: true });
725
- sel.cwd = r.path; $('#ghWrap', sh).hidden = true; paint(); toast(r.existed ? 'Ja el tenies. L’he seleccionat.' : 'Repositori clonat');
726
- } catch (e) { btn.disabled = false; toast(e.message, 6000); }
601
+ try { const r = await api('/api/github/clone', { method: 'POST', body: { repo } }); toast(r.existed ? 'Ja el tenies. L’he seleccionat.' : 'Repositori clonat'); await fsGo(r.path); }
602
+ catch (e) { btn.disabled = false; toast(e.message, 6000); }
603
+ };
604
+
605
+ const VIEWS_ = [
606
+ () => `<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>`,
607
+ () => {
608
+ const crumbs = fsd ? fsd.path.split(/[\\/]/).filter(Boolean) : [];
609
+ const quick = projects.slice(0, 8).map((p) => `<button type="button" class="chip" data-fs="${esc(p.path)}">${esc(p.name)}</button>`).join('');
610
+ return `<p class="wiz-q">A quina carpeta ha de treballar?</p>
611
+ <form class="pair-form" id="fsForm"><input type="text" id="fsIn" value="${esc(fsd?.path || sel.cwd)}" placeholder="/ruta/exacta/de/la/carpeta" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Ruta de la carpeta"><button class="btn" type="submit">Obre</button></form>
612
+ <div class="fs-pick"><span class="muted small">Carpeta triada</span><b class="mono small">${esc(fsd?.path || sel.cwd || '—')}</b>${fsd?.git ? '<span class="chip">git</span>' : ''}</div>
613
+ <div class="row"><button class="chip" type="button" data-gh>${gh ? 'Torna a les carpetes' : 'Clona de GitHub'}</button>${quick}</div>
614
+ ${gh ? (gh.msg ? `<p class="muted small">${gh.msg}</p>` : '') + (gh.repos ? `<input type="search" id="ghQ" value="${esc(gh.q)}" placeholder="Cerca entre els teus repositoris${gh.user ? ' (' + esc(gh.user) + ')' : ''}" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Cerca un repositori"><div class="proj-list fs-list" id="ghList">${ghList()}</div>` : '')
615
+ : !fsd ? '<p class="muted small">Carregant…</p>' : `<div class="fs-bar"><button class="icon-btn" type="button" data-fs="${esc(fsd.parent || '')}" ${fsd.parent ? '' : 'disabled'} aria-label="Carpeta de dalt">${svg(ICON.back)}</button><button class="chip" type="button" data-fs="${esc(fsd.home)}">Inici</button><span class="fs-path mono small" title="${esc(fsd.path)}">${esc(crumbs.slice(-2).join(' / ') || '/')}</span></div>
616
+ <div class="proj-list fs-list">${fsd.dirs.map((d) => `<button type="button" class="proj" data-fs="${esc(d.path)}"><span class="ic">›</span><span class="grow"><div ${d.hidden ? 'class="muted"' : ''}>${esc(d.name)}</div></span></button>`).join('') || '<p class="muted small">No hi ha subcarpetes. Pots triar aquesta.</p>'}</div>`}`;
617
+ },
618
+ () => {
619
+ const ag = agentOf();
620
+ const modelHtml = sel.agent === 'ollama'
621
+ ? `<label class="f"><span>Model</span><select id="modelSel">${(models || []).map((m) => `<option ${m.name === sel.model ? 'selected' : ''}>${esc(m.name)}</option>`).join('') || `<option value="">${models == null ? 'Carregant…' : 'Cap model (ollama pull qwen3:4b)'}</option>`}</select></label>`
622
+ : `<label class="f"><span>Model <span class="muted">(opcional)</span></span><input type="text" id="modelIn" value="${esc(sel.model)}" placeholder="El que l’agent faci servir per defecte" autocapitalize="off" spellcheck="false"></label>`;
623
+ return `<p class="wiz-q">Quins permisos té?</p>${ag?.noModes ? '<p class="muted small">Aquest agent no té modes de permisos.</p>' : `<div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-mode="${k}" aria-pressed="${k === sel.mode}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(sel.agent, k))}</div></button>`).join('')}</div>`}${modelHtml}`;
624
+ },
625
+ () => `<p class="wiz-q">Què ha de fer?</p>
626
+ <dl class="kv"><dt>Agent</dt><dd>${esc(AGENT_NAMES[sel.agent] || sel.agent)}</dd><dt>Carpeta</dt><dd class="mono">${esc(sel.cwd)}</dd>${agentOf()?.noModes ? '' : `<dt>Permisos</dt><dd>${esc(MODES[sel.mode].label)}</dd>`}${sel.model ? `<dt>Model</dt><dd class="mono">${esc(sel.model)}</dd>` : ''}</dl>
627
+ <label class="f"><span>Explica-li la tasca</span><textarea class="field" id="firstMsg" rows="5" placeholder="Per exemple: troba per què fallen els tests i arregla-ho">${esc(sel.text)}</textarea></label>`
628
+ ];
629
+
630
+ const start = async () => {
631
+ if (!sel.text.trim()) { $('#firstMsg', sh)?.focus(); return toast('Explica a l’agent què ha de fer'); }
632
+ const btn = $('[data-start]', sh); btn.disabled = true;
633
+ try { const { text, ...rest } = sel; const s = await api('/api/sessions', { method: 'POST', body: { ...rest, text: text.trim() } }); state.sessions.set(s.id, s); closeSheet(); go(`#/s/${s.id}`); }
634
+ catch (e) { toast(e.message, 4200); btn.disabled = false; }
727
635
  };
728
636
  sh.onclick = (e) => {
729
- const rp = e.target.closest('[data-repo]'); if (rp) return ghClone(rp.dataset.repo, rp);
730
- if (e.target.closest('[data-close]')) return closeSheet();
731
- const ag = e.target.closest('[data-agent-id]');
732
- if (ag) { const a = agents.find((x) => x.id === ag.dataset.agentId); if (!a.available) return toast(`Primer instal·la’l a l’ordinador: ${a.login}`, 4200); sel.agent = a.id; sel.model = ''; paint(); paintModel(); }
733
- const pj = e.target.closest('[data-path]'); if (pj) { sel.cwd = pj.dataset.path; paint(); }
734
- const mo = e.target.closest('[data-mode]'); if (mo) { sel.mode = mo.dataset.mode; paint(); }
637
+ const t = e.target;
638
+ if (t.closest('[data-close]')) return closeSheet();
639
+ if (t.closest('[data-back]')) { step = Math.max(0, step - 1); return draw(); }
640
+ if (t.closest('[data-next]')) {
641
+ if (step === 0 && !sel.agent) return toast('Tria un agent');
642
+ if (step === 1 && !sel.cwd) return toast('Tria una carpeta');
643
+ step++; return draw();
644
+ }
645
+ if (t.closest('[data-start]')) return start();
646
+ const st = t.closest('[data-step]'); if (st && !st.disabled) { step = +st.dataset.step; return draw(); }
647
+ const ag = t.closest('[data-agent-id]');
648
+ if (ag) { const a = agents.find((x) => x.id === ag.dataset.agentId); if (!a.available) return toast(`Primer instal·la’l a l’ordinador: ${a.login}`, 4200); if (a.id !== sel.agent) { sel.agent = a.id; sel.model = ''; models = null; } step = 1; return draw(); }
649
+ const fp = t.closest('[data-fs]'); if (fp && !fp.disabled) return fsGo(fp.dataset.fs);
650
+ if (t.closest('[data-gh]')) { if (gh) { gh = null; return body(); } return ghStart(); }
651
+ const rp = t.closest('[data-repo]'); if (rp) return ghClone(rp.dataset.repo, rp);
652
+ const mo = t.closest('[data-mode]'); if (mo) { sel.mode = mo.dataset.mode; return body(); }
735
653
  };
736
- paint(); paintModel();
737
- $('#startBtn', sh).onclick = async () => {
738
- const text = $('#firstMsg', sh).value.trim();
739
- if (!text) { $('#firstMsg', sh).focus(); return toast('Explica a l’agent què ha de fer'); }
740
- $('#startBtn', sh).disabled = true;
741
- try { const s = await api('/api/sessions', { method: 'POST', body: { ...sel, text } }); state.sessions.set(s.id, s); closeSheet(); go(`#/s/${s.id}`); }
742
- catch (e) { toast(e.message, 4200); $('#startBtn', sh).disabled = false; }
654
+ sh.onsubmit = (e) => { e.preventDefault(); if (e.target.id === 'fsForm') fsGo($('#fsIn', sh).value.trim()); };
655
+ sh.oninput = (e) => {
656
+ const id = e.target.id;
657
+ if (id === 'firstMsg') sel.text = e.target.value;
658
+ else if (id === 'modelIn') sel.model = e.target.value.trim();
659
+ else if (id === 'ghQ' && gh) { gh.q = e.target.value; $('#ghList', sh).innerHTML = ghList(); }
743
660
  };
744
- if (!isTouch) setTimeout(() => $('#firstMsg', sh)?.focus(), 80);
745
- });
661
+ sh.onchange = (e) => { if (e.target.id === 'modelSel') sel.model = e.target.value; };
662
+ draw();
663
+ }, 'wiz');
746
664
  }
747
665
  function modeSheet(s) {
748
666
  openSheet(`<h2>Permisos</h2><div class="list">${Object.entries(MODES).map(([k, m]) => `<button class="card" type="button" data-m="${k}" style="${k === s.mode ? 'border-color:var(--fg);box-shadow:inset 0 0 0 1px var(--fg)' : ''}"><b>${m.label}</b><div class="muted small" style="margin-top:4px">${esc(modeHelp(s.agent, k))}</div></button>`).join('')}</div><p class="muted small">El canvi s’aplica a partir del pròxim missatge.</p>`, (sh) => {
@@ -753,7 +671,6 @@ function modeSheet(s) {
753
671
  });
754
672
  }
755
673
  /* ---------- Ordinadors i mòbil */
756
- const isRealMachine = () => { const m = machine(); return !!m && m.url !== DEMO_URL; };
757
674
  const isLocalMachine = () => { const m = machine(); try { return !!m && ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(m.url).hostname); } catch { return false; } };
758
675
  function mobileHint() {
759
676
  if (!isLocalMachine() || !state.connected || LS.get('nio.mobHint.off', false)) return '';
@@ -761,7 +678,7 @@ function mobileHint() {
761
678
  }
762
679
  function machinesSheet() {
763
680
  const ms = store.machines;
764
- openSheet(`<h2>Ordinadors</h2><div class="stack">${ms.map((x, i) => `<button class="card row" type="button" data-machine="${i}" style="flex-wrap:nowrap"><i class="dot ${i === store.active && state.connected ? 'on' : ''}"></i><span class="grow"><b>${esc(x.name)}</b><div class="muted small mono" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(x.url === DEMO_URL ? 'Dins del navegador' : x.url)}</div></span>${i === store.active ? '<span class="chip">Actiu</span>' : ''}</button>`).join('')}
681
+ openSheet(`<h2>Ordinadors</h2><div class="stack">${ms.map((x, i) => `<button class="card row" type="button" data-machine="${i}" style="flex-wrap:nowrap"><i class="dot ${i === store.active && state.connected ? 'on' : ''}"></i><span class="grow"><b>${esc(x.name)}</b><div class="muted small mono" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(x.url)}</div></span>${i === store.active ? '<span class="chip">Actiu</span>' : ''}</button>`).join('')}
765
682
  <div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button><button class="btn ink" type="button" data-act="add-machine">${svg(ICON.plus)}Connecta’n un altre</button></div></div>`, (sh) => { $('[data-close]', sh).onclick = closeSheet; });
766
683
  }
767
684
  function addMachineSheet() {
@@ -769,8 +686,9 @@ function addMachineSheet() {
769
686
  openSheet(`<h2>Connecta un altre ordinador</h2>
770
687
  <div class="stack-lg">
771
688
  <div class="stack"><b>Un ordinador</b><p class="muted small">Al terminal de l’altre ordinador, des de la carpeta dels projectes:</p>${cmd('npx nio-cat')}<p class="muted small">Escaneja el QR que surt, o enganxa aquí sota l’enllaç.</p></div>
772
- <div class="stack"><b>Un servidor (VPS)</b><p class="muted small">Al servidor, amb l’adreça pública amb HTTPS (Tailscale, Cloudflare o Caddy):</p>${cmd('npx nio-cat --url https://la-teva-adreça --no-open')}<p class="muted small">Deixa’l sota <span class="mono">tmux</span>, <span class="mono">pm2</span> o <span class="mono">systemd</span> perquè no s’aturi. Copia l’enllaç que mostra i enganxa’l aquí.</p></div>
773
- <form class="pair-form" id="pairForm"><input type="url" id="pairInput" placeholder="https://…/app#u=…&t=…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Enllaç de connexió"><button class="btn ink" type="submit">Connecta</button></form>
689
+ <div class="stack"><b>Un servidor (VPS) per SSH</b><p class="muted small">Des de l’ordinador on tens aquest panell. No cal obrir cap port al servidor, només Node.js 18 o més nou:</p>${cmd('npx nio-cat ssh usuari@servidor')}<p class="muted small">Engega nio al servidor, obre un túnel SSH i et mostra el panell connectat.</p></div>
690
+ <div class="stack"><b>Un servidor amb adreça pública</b><p class="muted small">Si el servidor té una adreça amb HTTPS (Tailscale, Cloudflare o Caddy):</p>${cmd('npx nio-cat --url https://la-teva-adreça --no-open')}<p class="muted small">Deixa’l sota <span class="mono">tmux</span>, <span class="mono">pm2</span> o <span class="mono">systemd</span>. Copia aquí el codi que mostra.</p></div>
691
+ <form class="pair-form" id="pairForm"><input type="text" id="pairInput" placeholder="nio-… o enllaç" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Codi o enllaç de connexió"><button class="btn ink" type="submit">Connecta</button></form>
774
692
  </div>`);
775
693
  }
776
694
  let qrTimer = 0;
@@ -785,7 +703,7 @@ function mobileQrSheet() {
785
703
  if (n < 8) qrTimer = setTimeout(() => paint(n + 1), 2000);
786
704
  return;
787
705
  }
788
- box.innerHTML = `<div class="qr-box">${r.svg}</div><p class="muted small">Escaneja-ho amb la càmera del mòbil.${r.lan ? ' El mòbil ha d’estar a la mateixa wifi.' : ''}</p><button class="btn" type="button" data-copy="${esc(r.link)}">${svg(ICON.copy)}Copia l’enllaç</button>`;
706
+ box.innerHTML = `<div class="qr-box">${r.svg}</div><p class="muted small">Escaneja-ho amb la càmera del mòbil.${r.lan ? ' El mòbil ha d’estar a la mateixa wifi.' : ''}</p><div class="row"><button class="btn" type="button" data-copy="${esc(r.code)}">${svg(ICON.copy)}Copia el codi</button></div><p class="muted small">Si no pots escanejar, enganxa el codi a Sessions → Tria un ordinador.</p>`;
789
707
  };
790
708
  openSheet(`<h2>Continua al mòbil</h2><div class="stack" id="qrBox"><p class="muted small">Preparant el codi…</p></div><div class="row sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button></div>`, (sh) => { $('[data-close]', sh).onclick = closeSheet; });
791
709
  paint();
@@ -826,7 +744,7 @@ function sessionOptions(id) {
826
744
  openSheet(`<h2>${esc(s.title || 'Sessió')}</h2>
827
745
  <dl class="kv"><dt>Agent</dt><dd>${esc(AGENT_NAMES[s.agent] || s.agent)}</dd><dt>Carpeta</dt><dd class="mono">${esc(s.cwd)}</dd><dt>Permisos</dt><dd>${esc(MODES[s.mode]?.label || '')}</dd><dt>Creada</dt><dd>${new Date(s.created).toLocaleString('ca')}</dd></dl>
828
746
  <label class="f"><span>Nom de la sessió</span><input type="text" id="renameIn" value="${esc(s.title)}" maxlength="120"></label>
829
- ${isRealMachine() ? '<button class="btn" data-act="git" type="button">Git i GitHub…</button>' : ''}
747
+ <button class="btn" data-act="git" type="button">Git i GitHub…</button>
830
748
  <div class="row between"><button class="btn danger" id="delBtn" type="button">Esborra la sessió</button><button class="btn ink" id="renameBtn" type="button">Desa</button></div>`, (sh) => {
831
749
  $('#renameBtn', sh).onclick = async () => { const t = $('#renameIn', sh).value.trim(); const sum = await api(`/api/sessions/${id}`, { method: 'PATCH', body: { title: t } }).catch((e) => toast(e.message)); if (sum) state.sessions.set(id, sum); closeSheet(); renderSessionHead(); };
832
750
  $('#delBtn', sh).onclick = async () => { if (!confirm('S’esborrarà la conversa d’aquesta sessió. Els fitxers del projecte no es toquen.')) return; await api(`/api/sessions/${id}`, { method: 'DELETE' }).catch((e) => toast(e.message)); closeSheet(); go('#/sessions'); };
@@ -836,8 +754,7 @@ function sessionOptions(id) {
836
754
  /* ---------- Ordinador */
837
755
  function viewMachine() {
838
756
  const m = machine(), info = state.info, ms = store.machines;
839
- if (!m) return viewConnect();
840
- return `<div class="top"><div><h1>${esc(m.name)}</h1><p class="conn"><i class="dot ${state.connected ? 'on' : ''}"></i><span>${state.connected ? 'Connectat' : esc(state.lastError || 'Connectant…')}</span></p></div><button class="btn" data-act="refresh-agents" type="button">${svg(ICON.refresh)}Torna a detectar els agents</button></div>
757
+ return `<div class="top"><div><h1>${esc(m.name)}</h1><p class="conn"><i class="dot ${state.connected ? 'on' : ''}"></i><span>${state.connected ? 'Connectat' : esc(state.lastError || 'Connectant…')}</span></p></div><div class="row"><button class="btn" data-act="refresh-agents" type="button">${svg(ICON.refresh)}Torna a detectar els agents</button><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div></div>
841
758
  <div class="grid2">
842
759
  <div class="stack">
843
760
  <section class="panel"><header><h2>Agents</h2></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">Sense connexió.</p>'}</div></section>
@@ -845,9 +762,9 @@ function viewMachine() {
845
762
  </div>
846
763
  <div class="stack">
847
764
  <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>
848
- ${m.url !== DEMO_URL ? '<section class="panel"><header><h2>Continua al mòbil</h2></header><div class="body stack"><p class="small">Escaneja un codi i obre aquest ordinador des del telèfon.</p><div class="row"><button class="btn ink grow" data-act="mobile-qr" type="button">Mostra el QR</button></div></div></section>' : ''}
849
- <section class="panel"><header><h2>Detalls</h2></header><div class="body"><dl class="kv"><dt>Adreça</dt><dd class="mono">${esc(m.url === DEMO_URL ? 'Dins del navegador' : m.url)}</dd>${info ? `<dt>Sistema</dt><dd>${esc(info.platform)} · ${esc(info.user)}</dd><dt>Versió</dt><dd>${esc(info.version)}</dd>` : ''}</dl></div></section>
850
- <section class="panel"><header><h2>Ordinadors</h2></header><div class="body stack">${ms.map((x, i) => `<button class="card row" type="button" data-machine="${i}" style="flex-wrap:nowrap"><i class="dot ${i === store.active && state.connected ? 'on' : ''}"></i><span class="grow"><b>${esc(x.name)}</b><div class="muted small mono" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(x.url === DEMO_URL ? 'Dins del navegador' : x.url)}</div></span>${i === store.active ? '<span class="chip">actiu</span>' : ''}</button>`).join('')}
765
+ ${'<section class="panel"><header><h2>Continua al mòbil</h2></header><div class="body stack"><p class="small">Escaneja un codi i obre aquest ordinador des del telèfon.</p><div class="row"><button class="btn ink grow" data-act="mobile-qr" type="button">Mostra el QR</button></div></div></section>'}
766
+ <section class="panel"><header><h2>Detalls</h2></header><div class="body"><dl class="kv"><dt>Adreça</dt><dd class="mono">${esc(m.url)}</dd>${info ? `<dt>Sistema</dt><dd>${esc(info.platform)} · ${esc(info.user)}</dd><dt>Versió</dt><dd>${esc(info.version)}</dd>` : ''}</dl></div></section>
767
+ <section class="panel"><header><h2>Ordinadors</h2></header><div class="body stack">${ms.map((x, i) => `<button class="card row" type="button" data-machine="${i}" style="flex-wrap:nowrap"><i class="dot ${i === store.active && state.connected ? 'on' : ''}"></i><span class="grow"><b>${esc(x.name)}</b><div class="muted small mono" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(x.url)}</div></span>${i === store.active ? '<span class="chip">actiu</span>' : ''}</button>`).join('')}
851
768
  <div class="row"><button class="btn" data-act="add-machine" type="button">${svg(ICON.plus)}Connecta’n un altre</button><button class="btn danger" data-act="forget" type="button">Oblida aquest</button></div></div></section>
852
769
  </div>
853
770
  </div>`;
@@ -939,7 +856,7 @@ function onOllama(msg) {
939
856
  window.NioBrains = {
940
857
  list() {
941
858
  const out = [{ id: 'rules', label: 'Ordres ràpides' }];
942
- if (machine() && state.connected) (state.ollama?.models || []).forEach((m) => out.push({ id: 'ollama:' + m.name, label: `Ollama · ${m.name}${isDemo() ? ' (simulat)' : ''}` }));
859
+ if (machine() && state.connected) (state.ollama?.models || []).forEach((m) => out.push({ id: 'ollama:' + m.name, label: `Ollama · ${m.name}` }));
943
860
  if (local.engine) out.push({ id: 'local', label: `Xat local · ${LOCAL_MODELS.find((x) => x.id === local.model)?.name || local.model}` });
944
861
  return out;
945
862
  },
@@ -966,7 +883,6 @@ async function brainsForBoard() {
966
883
  function b64uToU8(s) { const b = atob((s + '='.repeat((4 - (s.length % 4)) % 4)).replace(/-/g, '+').replace(/_/g, '/')); return Uint8Array.from(b, (c) => c.charCodeAt(0)); }
967
884
  async function enablePush() {
968
885
  try {
969
- if (isDemo()) throw new Error('Els avisos només funcionen amb un ordinador de veritat');
970
886
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) throw new Error(isIOS ? 'Afegeix nio a la pantalla d’inici i obre’l des d’allà' : 'Aquest navegador no admet avisos');
971
887
  if ((await Notification.requestPermission()) !== 'granted') throw new Error('Cal que permetis les notificacions');
972
888
  const reg = await navigator.serviceWorker.ready;
@@ -976,7 +892,7 @@ async function enablePush() {
976
892
  } catch (e) { toast(e.message, 4200); }
977
893
  }
978
894
  async function syncServiceWorker() {
979
- const m = machine(); if (!m || m.url === DEMO_URL || !('serviceWorker' in navigator)) return;
895
+ const m = machine(); if (!m || !('serviceWorker' in navigator)) return;
980
896
  try { (await navigator.serviceWorker.ready).active?.postMessage({ type: 'nio-cfg', url: m.url, token: m.token }); } catch { /* sense service worker */ }
981
897
  }
982
898
 
@@ -1085,7 +1001,6 @@ document.addEventListener('click', async (e) => {
1085
1001
  case 'ollama-cancel': try { await api(`/api/ollama/pull/${encodeURIComponent(a.dataset.name)}`, { method: 'DELETE' }); } catch (err) { toast(err.message); } return;
1086
1002
  case 'ollama-del': return deleteModel(a.dataset.name);
1087
1003
  case 'ollama-chat': try { state.info = await api('/api/info?refresh=1'); } catch { /* res */ } return newSessionSheet({ agent: 'ollama', model: a.dataset.name });
1088
- case 'demo': return tryDemo();
1089
1004
  case 'retry': return connect();
1090
1005
  case 'mode': { const s = state.sessions.get(state.route.id); if (s) modeSheet(s); return; }
1091
1006
  case 'session-more': return sessionOptions(state.route.id);
@@ -1100,7 +1015,7 @@ document.addEventListener('click', async (e) => {
1100
1015
  case 'forget': {
1101
1016
  if (!confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) return;
1102
1017
  const list = store.machines; list.splice(store.active, 1); store.machines = list; store.active = 0;
1103
- streamCtrl?.abort(); unsubDemo?.(); state.connected = false; state.info = null; state.sessions.clear(); state.approvals.clear();
1018
+ streamCtrl?.abort(); state.connected = false; state.info = null; state.sessions.clear(); state.approvals.clear();
1104
1019
  connect(); return render();
1105
1020
  }
1106
1021
  case 'lm-load': return loadLocal(local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id));
@@ -1115,8 +1030,8 @@ document.addEventListener('submit', (e) => {
1115
1030
  if (e.target.id === 'pullForm') { e.preventDefault(); return pullModel($('#pullIn').value); }
1116
1031
  if (e.target.id !== 'pairForm') return;
1117
1032
  e.preventDefault();
1118
- const pair = parsePair($('#pairInput', e.target).value);
1119
- if (!pair) return toast('Aquest enllaç no és de connexió');
1033
+ const pair = pairFrom($('#pairInput', e.target).value);
1034
+ if (!pair) return toast('Aquest codi no és de connexió. Copia’l sencer del terminal.');
1120
1035
  closeSheet(); addMachine(pair); toast('Connectant…'); go('#/sessions');
1121
1036
  });
1122
1037
 
@@ -1126,7 +1041,6 @@ function commands() {
1126
1041
  const c = Object.entries(VIEWS).map(([k, v]) => ({ label: `Ves a ${v.label}`, hint: 'Navega', run: () => go(HASH[k]) }));
1127
1042
  if (machine()) c.push({ label: 'Nova sessió', hint: 'Crea', run: newSessionSheet });
1128
1043
  [...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}`) }));
1129
- if (!isDemo()) c.push({ label: 'Prova l’ordinador simulat', hint: 'Prova', run: tryDemo });
1130
1044
  c.push({ label: 'Fes el tutorial', hint: 'Ajuda', run: tourStart });
1131
1045
  return c;
1132
1046
  }
@@ -1202,8 +1116,7 @@ const T = Object.assign({ on: false, i: 0, seen: false }, LS.get(TOUR_KEY, {}));
1202
1116
  const saveTour = () => LS.set(TOUR_KEY, { on: T.on, i: T.i, seen: T.seen });
1203
1117
  const TSTEPS = [
1204
1118
  { center: true, mood: 'happy', title: 'Hola, sóc en Nio.', text: 'Faig de pont entre el teu mòbil i els agents d’IA del teu ordinador. En un minut t’ensenyo on és cada cosa.', next: 'Comencem' },
1205
- { route: 'sessions', target: () => (machine() ? '.top [data-act=new]' : '.conn-main'), m: () => (machine() ? '.top [data-act=new]' : '.conn-main .cmd'), title: () => (machine() ? 'Dona feina a un agent' : 'Primer, connecta l’ordinador'), text: () => (machine() ? 'Cada sessió és una conversa amb un agent dins d’una carpeta. Tria l’agent, els permisos i explica-li què ha de fer.' : 'Executa aquesta ordre al terminal de l’ordinador i escaneja el codi QR que apareix. Si encara no el tens a mà, pots provar-ho amb un ordinador simulat.') },
1206
- { route: 'sessions', target: () => (machine() ? null : '.try'), m: () => (machine() ? null : '.try [data-act=demo]'), skipIf: () => !!machine(), title: 'Prova-ho sense instal·lar res', text: 'L’ordinador simulat té un agent de demostració que llegeix, et demana permís i executa. Ideal per veure com funciona.' },
1119
+ { route: 'sessions', target: () => (machine() ? '.top [data-act=new]' : '.conn-main'), m: () => (machine() ? '.top [data-act=new]' : '.conn-main .cmd'), title: () => (machine() ? 'Dona feina a un agent' : 'Primer, connecta l’ordinador'), text: () => (machine() ? 'Cada sessió és una conversa amb un agent dins d’una carpeta. Tria l’agent, els permisos i explica-li què ha de fer.' : 'Executa aquesta ordre al terminal de l’ordinador i escaneja el codi QR que apareix.') },
1207
1120
  { route: 'approvals', target: '.side nav a[href="#/aprovacions"]', m: '#mRight .icon-btn', title: '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.' },
1208
1121
  { route: 'mobil', target: '.side nav a[href="#/mobil"]', 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.' },
1209
1122
  { route: 'models', target: '.side nav a[href="#/models"]', m: '.page .top h1, .connect-head h1', title: 'Models al teu ordinador', text: 'Si tens Ollama, aquí descarregues models, veus quins tens i obres sessions amb el que vulguis. Res no surt de casa.' },
@@ -1305,12 +1218,17 @@ if ('serviceWorker' in navigator) {
1305
1218
  navigator.serviceWorker.addEventListener('message', (e) => { if (e.data?.type === 'open-session' && e.data.id) go(`#/s/${e.data.id}`); });
1306
1219
  }
1307
1220
  addEventListener('hashchange', route);
1308
- document.addEventListener('visibilitychange', () => { if (!document.hidden && machine() && !state.connected && !isDemo()) connect(); });
1221
+ document.addEventListener('visibilitychange', () => { if (!document.hidden && machine() && !state.connected) connect(); });
1309
1222
  setInterval(() => { if (!document.hidden && state.route.name === 'sessions') render(); }, 60000);
1310
1223
  const qs = new URLSearchParams(location.search).get('s');
1311
1224
  if (qs) history.replaceState(null, '', location.pathname + `#/s/${qs}`);
1312
1225
  // Les dades del panell antic (simulació d'agents d'operacions) ja no es fan servir.
1313
1226
  try { localStorage.removeItem('nio.panell.v1'); localStorage.removeItem('nio.tour.v1'); } catch { /* res */ }
1227
+ // A la còpia local (127.0.0.1, localhost) la icona té el fons groc suau, per distingir-la de www.nio.cat.
1228
+ if (['127.0.0.1', 'localhost', '[::1]'].includes(location.hostname)) {
1229
+ const ico = document.querySelector('link[rel="icon"]');
1230
+ if (ico) ico.href = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#f6e9a0"/><circle cx="16" cy="16" r="10.2" fill="none" stroke="#272224" stroke-width="3.6"/><rect x="11.9" y="12.6" width="2.6" height="6" rx="1.3" fill="#272224"/><rect x="17.5" y="12.6" width="2.6" height="6" rx="1.3" fill="#272224"/></svg>');
1231
+ }
1314
1232
  setNav(false);
1315
1233
  if (!checkPairHash()) state.route = parseRoute();
1316
1234
  if (machine()) connect();
package/web/app/style.css CHANGED
@@ -148,7 +148,7 @@ button.chip:hover { border-color: var(--fg); }
148
148
  .connect { display: grid; gap: 24px; min-width: 0; }
149
149
  .connect-head h1 { font-size: clamp(30px, 4vw, 40px); font-weight: 500; letter-spacing: -.05em; line-height: 1.08; }
150
150
  .connect-head .lead { color: var(--fg-muted); font-size: 16px; max-width: 58ch; margin-top: 10px; }
151
- .connect-grid { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(260px, 1fr); gap: 16px; align-items: start; }
151
+ .connect-grid { display: grid; grid-template-columns: minmax(0, 720px); gap: 16px; align-items: start; }
152
152
  .conn-main { padding: 22px; display: grid; gap: 16px; min-width: 0; }
153
153
  .stepper { list-style: none; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
154
154
  .stepper li { display: flex; gap: 10px; align-items: flex-start; }
@@ -379,3 +379,43 @@ body.board-mode .view-enter { animation: none; }
379
379
  .qr-box { width: min(260px, 100%); margin: 0 auto; padding: 14px; border-radius: 16px; background: #fffefd; border: 1px solid var(--border); }
380
380
  .qr-box svg { display: block; width: 100%; height: auto; }
381
381
  #ghWrap input[type="search"] { margin-bottom: 4px; }
382
+
383
+ /* Connexió: dues opcions a l'ordinador; explorador de carpetes a Nova sessió */
384
+ .choice-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; align-items: start; }
385
+ .choice .body { min-width: 0; }
386
+ @media (max-width: 820px) { .choice-grid { grid-template-columns: 1fr; } }
387
+ .fs-bar { display: flex; align-items: center; gap: 8px; min-width: 0; }
388
+ .fs-path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; }
389
+ .fs-list { max-height: 240px; overflow: auto; }
390
+
391
+ /* Assistent «Nova sessió»: drawer fix al telèfon, capçalera i peu fixos, cos amb scroll */
392
+ body:has(dialog.sheet[open]) { overflow: hidden; }
393
+ dialog.sheet { overscroll-behavior: contain; }
394
+ dialog.sheet.wiz { height: min(680px, 88dvh); overflow: hidden; }
395
+ dialog.sheet.wiz[open] { display: flex; flex-direction: column; }
396
+ dialog.sheet.wiz .sheet-in { padding: 0; gap: 0; flex: 1; min-height: 0; display: flex; flex-direction: column; }
397
+ .wiz-head { flex: none; display: grid; gap: 8px; padding: 20px 24px 12px; }
398
+ .wiz-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 6px 24px 18px; display: grid; gap: 14px; align-content: start; }
399
+ .wiz-foot { flex: none; display: flex; justify-content: space-between; gap: 10px; padding: 12px 24px calc(16px + var(--safe-b)); border-top: 1px solid var(--border); background: var(--bg); }
400
+ .wiz-foot .btn { min-width: 110px; justify-content: center; }
401
+ .wiz-q { font-size: 18px; font-weight: 500; letter-spacing: -.03em; }
402
+ .wiz-dots { display: flex; gap: 6px; }
403
+ .wiz-dot { flex: 1; height: 18px; border: 0; background: var(--border) content-box; background-clip: content-box; padding: 7px 0; border-radius: 4px; }
404
+ .wiz-dot.on { background-color: var(--fg); }
405
+ .wiz-dot.done { background-color: var(--moss); }
406
+ .wiz-dot:disabled { cursor: default; }
407
+ .wiz .card[aria-pressed="true"] { border-color: var(--fg); box-shadow: inset 0 0 0 1px var(--fg); }
408
+ .fs-pick { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 10px 12px; border-radius: 10px; background: var(--bg-2); }
409
+ .fs-pick b { overflow-wrap: anywhere; }
410
+ .fs-list { max-height: none; }
411
+ @media (max-width: 820px) {
412
+ dialog.sheet.wiz { height: min(92dvh, calc(var(--vvh, 100dvh) - 8px)); }
413
+ .wiz-head { padding: 10px 18px 8px; }
414
+ .wiz-body { padding: 4px 18px 14px; }
415
+ .wiz-foot { padding: 12px 18px calc(14px + var(--safe-b)); }
416
+ .wiz-foot .btn { flex: 1; }
417
+ }
418
+ #fsForm { display: flex; gap: 8px; }
419
+ #fsForm input { flex: 1; min-width: 0; }
420
+ .pair-code { display: grid; gap: 4px; text-align: center; }
421
+ .pair-code b { font-size: 30px; letter-spacing: .14em; font-weight: 600; cursor: copy; }
package/web/index.html CHANGED
@@ -116,11 +116,6 @@ main strong { font-weight: 600; }
116
116
  <h2 id="limits">Tu decideixes</h2>
117
117
  <p>Cada sessió té el seu nivell de confiança: <strong>només llegir</strong>, <strong>demana permís</strong> abans d’editar o executar, o <strong>via lliure</strong>. Quan l’agent et necessita, t’arriba un avís i ho resols amb un toc.</p>
118
118
  </section>
119
-
120
- <section aria-labelledby="prova">
121
- <h2 id="prova">Prova-ho</h2>
122
- <p>Sense instal·lar res, amb un ordinador simulat: <a href="/app">obre el panell</a>. També hi trobaràs els teus models d’Ollama i un model petit que funciona dins del mateix mòbil.</p>
123
- </section>
124
119
  </main>
125
120
 
126
121
  <footer class="footer">