nio-cat 0.3.5 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web/app/app.js CHANGED
@@ -28,6 +28,8 @@ const ICON = {
28
28
  more: '<circle cx="5" cy="12" r="1.3" fill="currentColor"/><circle cx="12" cy="12" r="1.3" fill="currentColor"/><circle cx="19" cy="12" r="1.3" fill="currentColor"/>',
29
29
  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
30
  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
+ 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"/>',
32
+ 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"/>',
31
33
  check: '<path class="tick" d="M5 12.5l4.5 4.5L19 7.5"/>',
32
34
  copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/>'
33
35
  };
@@ -217,38 +219,60 @@ function parseCode(text) {
217
219
  }
218
220
  // Un codi o un enllaç de connexió (#u=…&t=…).
219
221
  const pairFrom = (text) => parsePair(text) || parseCode(text);
220
- function addMachine({ url, token, name }) {
222
+ // Cap pantalla ensenya adreces: cada ordinador és un nom, una icona i un estat.
223
+ const hostOf = (u) => { try { return new URL(u).hostname; } catch { return ''; } };
224
+ const isLoop = (u) => ['127.0.0.1', 'localhost', '[::1]'].includes(hostOf(u));
225
+ const machineIcon = (m) => (m.kind === 'vps' ? ICON.server : ICON.machine);
226
+ const machineSub = (m) => (m.kind === 'vps' ? `Servidor${m.via ? ` · ${m.via}` : ''}` : isLoop(m.url) ? 'Aquest ordinador' : 'Ordinador');
227
+ function addMachine({ url, token, name, kind, via }) {
221
228
  const ms = store.machines; let i = ms.findIndex((m) => m.url === url);
222
- if (i >= 0) ms[i].token = token; else { ms.push({ url, token, name: name || new URL(url).hostname }); i = ms.length - 1; }
229
+ if (i >= 0) { ms[i].token = token; if (kind) ms[i].kind = kind; if (via) ms[i].via = via; }
230
+ else { ms.push({ url, token, name: name || hostOf(url), ...(kind ? { kind } : {}), ...(via ? { via } : {}) }); i = ms.length - 1; }
223
231
  store.machines = ms; store.active = i;
224
232
  state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.info = null; state.ollama = null;
225
233
  connect();
226
234
  }
235
+ // Passa a un altre ordinador de la llista i es queda a la mateixa secció.
236
+ function switchMachine(i) {
237
+ closeSheet(); store.active = i; streamCtrl?.abort();
238
+ state.ollama = null; state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.connected = false; state.info = null; state.lastError = null;
239
+ connect();
240
+ if (state.route.name === 'session') go('#/sessions'); else render();
241
+ }
242
+ function forgetMachine(i) {
243
+ const ms = store.machines, was = i === store.active; ms.splice(i, 1); store.machines = ms;
244
+ if (was) store.active = 0; else if (i < store.active) store.active = store.active - 1;
245
+ if (was) { streamCtrl?.abort(); state.connected = false; state.info = null; state.ollama = null; state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); connect(); }
246
+ if (!ms.length) go('#/ordinadors'); else render();
247
+ }
227
248
  function checkPairHash() {
228
249
  const pair = parsePair(location.hash) || parseCode(location.hash); if (!pair) return false;
250
+ const hashAtStart = location.hash;
229
251
  history.replaceState(null, '', location.pathname + '#/sessions');
230
252
  state.route = { name: 'sessions' };
231
- addMachine(pair); render(); toast('Ordinador connectat'); return true;
253
+ const wantQr = new URLSearchParams(location.hash.replace(/^#/, '')).get('qr') === '1' || /[#&]qr=1\b/.test(hashAtStart);
254
+ addMachine(pair); render(); toast('Ordinador connectat');
255
+ // Quan nio obre el panell, el QR per al mòbil surt tot seguit, sense cap pestanya nova.
256
+ if (wantQr && !isPhone()) setTimeout(mobileQrSheet, 500);
257
+ return true;
232
258
  }
233
259
 
234
260
  /* ---------------------------------------------------------------- navegació */
235
261
  const VIEWS = {
236
262
  sessions: { label: 'Sessions', icon: ICON.sessions, group: 'Feina' },
237
263
  approvals: { label: 'Aprovacions', icon: ICON.approvals, group: 'Feina' },
238
- models: { label: 'Models', icon: ICON.models, group: 'Ordinador' },
239
- maquina: { label: 'Ordinador', icon: ICON.machine, group: 'Ordinador' },
264
+ ordinadors: { label: 'Ordinadors', icon: ICON.machine, group: 'Ordinadors' },
265
+ models: { label: 'Models', icon: ICON.models, group: 'Ordinadors' },
240
266
  mobil: { label: 'Xat local', icon: ICON.phone, group: 'Eines' },
241
267
  pissarra: { label: 'Pissarra', icon: ICON.board, group: 'Eines' }
242
268
  };
243
- const NOMACHINE = {
244
- approvals: { title: 'Aprovacions', lead: 'Quan un agent vol editar un fitxer o executar una ordre, t’ho demana aquí.' },
245
- models: { title: 'Models', lead: 'Models d’Ollama que funcionen a l’ordinador, sense internet i sense comptes.' },
246
- maquina: { title: 'Ordinador', lead: 'Els agents instal·lats, els avisos al mòbil i els ordinadors connectats.' }
247
- };
248
- const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', mobil: '#/mobil', models: '#/models', maquina: '#/ordinador', pissarra: '#/pissarra' };
269
+ const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', ordinadors: '#/ordinadors', models: '#/models', mobil: '#/xat-local', pissarra: '#/pissarra' };
270
+ const NEEDS_MACHINE = new Set(['sessions', 'approvals', 'models', 'session']);
249
271
  function parseRoute() {
250
272
  const h = location.hash.replace(/^#\/?/, '');
251
273
  if (h.startsWith('s/')) return { name: 'session', id: h.slice(2) };
274
+ if (h === 'ordinador') return { name: 'ordinadors' }; // adreces antigues
275
+ if (h === 'mobil') return { name: 'mobil' };
252
276
  const k = Object.keys(HASH).find((k) => HASH[k] === '#/' + h);
253
277
  return { name: k || 'sessions' };
254
278
  }
@@ -257,6 +281,8 @@ let lastName = null;
257
281
  function route() {
258
282
  if (checkPairHash()) return;
259
283
  state.route = parseRoute();
284
+ // Sense cap ordinador, tot porta al mateix lloc: la pàgina Ordinadors.
285
+ if (!machine() && NEEDS_MACHINE.has(state.route.name)) { history.replaceState(null, '', '#/ordinadors'); state.route = { name: 'ordinadors' }; }
260
286
  if (state.route.name === 'session') openSession(state.route.id); else render();
261
287
  }
262
288
 
@@ -269,10 +295,13 @@ function renderChrome() {
269
295
  prev = v.group;
270
296
  return `${head}<a href="${HASH[k]}" style="--i:${i}" title="${v.label}" ${k === active ? 'aria-current="page"' : ''}>${svg(v.icon)}<span class="l">${v.label}</span>${k === 'approvals' && n ? `<span class="badge">${n}</span>` : ''}</a>`;
271
297
  }).join('');
272
- const m = machine();
273
- const many = store.machines.length > 1, sc = $('#sideConn');
274
- sc.innerHTML = m ? `<i class="dot ${state.connected ? 'on' : ''}"></i><span>${esc(state.connected ? m.name : state.lastError ? 'Sense connexió' : 'Connectant…')}</span>${many ? svg(ICON.chev) : ''}` : '<i class="dot"></i><span>Cap ordinador connectat</span>';
275
- sc.classList.toggle('switch', many); many ? (sc.setAttribute('role', 'button'), sc.tabIndex = 0, sc.dataset.act = 'machines', sc.title = 'Canvia d’ordinador') : (sc.removeAttribute('role'), sc.removeAttribute('tabindex'), delete sc.dataset.act, sc.removeAttribute('title'));
298
+ const m = machine(), sc = $('#sideConn');
299
+ sc.innerHTML = m ? `<i class="dot ${state.connected ? 'on' : ''}"></i><span>${esc(m.name)}</span>${svg(ICON.chev)}` : `<i class="dot"></i><span>Afegeix un ordinador</span>${svg(ICON.plus)}`;
300
+ sc.setAttribute('role', 'button'); sc.tabIndex = 0; sc.classList.add('switch'); sc.dataset.act = 'machines'; sc.title = 'Ordinadors';
301
+ const more = ['models', 'mobil', 'pissarra'].includes(r.name);
302
+ $('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['approvals', ICON.approvals, 'Aprovacions', active === 'approvals'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
303
+ .map(([k, ic, l, on]) => `<a href="${HASH[k]}" ${on ? 'aria-current="page"' : ''}>${svg(ic)}<span>${l}</span>${k === 'approvals' && n ? `<b class="badge">${n}</b>` : ''}</a>`).join('')
304
+ + `<button type="button" data-act="more" ${more ? 'aria-current="page"' : ''}>${svg(ICON.more)}<span>Més</span></button>`;
276
305
  const s = r.name === 'session' ? state.sessions.get(r.id) : null;
277
306
  $('#here').textContent = s ? s.title || 'Sessió nova' : VIEWS[r.name]?.label || '';
278
307
  $('#mRight').innerHTML = r.name === 'session'
@@ -286,6 +315,7 @@ function render() {
286
315
  renderChrome();
287
316
  document.body.classList.toggle('board-mode', board);
288
317
  document.body.classList.toggle('chat-mode', r.name === 'session' || (r.name === 'mobil' && !!local.engine));
318
+ document.body.classList.toggle('no-tabs', r.name === 'session' || board || (r.name === 'mobil' && !!local.engine));
289
319
  if (!board && lastName === 'pissarra') window.NioBoard?.unmount();
290
320
  if (board && lastName === 'pissarra' && $('#boardRoot')) return;
291
321
  const changed = lastName !== r.name || r.name === 'session';
@@ -294,7 +324,7 @@ function render() {
294
324
  else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
295
325
  else {
296
326
  const html = r.name === 'mobil' ? viewLocal() : null;
297
- 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>`;
327
+ view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html : r.name === 'ordinadors' ? viewComputers() : r.name === 'sessions' ? viewSessions() : r.name === 'approvals' ? viewApprovals() : viewModels()}</div>`;
298
328
  if (r.name === 'mobil') bindLocal();
299
329
  if (r.name === 'models' && machine()) loadOllama();
300
330
  }
@@ -303,48 +333,164 @@ function render() {
303
333
  tourUpdate();
304
334
  }
305
335
 
306
- /* ---------- Connecta */
307
- // La connexió viu només a Sessions. Les altres pàgines, sense ordinador, hi envien.
336
+ /* ---------- Ordinadors (llista, afegir i detall de l'actiu) */
308
337
  const isPhone = () => isTouch || matchMedia('(max-width: 820px)').matches;
309
- function viewNoMachine(what) {
310
- return `<div class="top"><div><h1>${esc(what.title)}</h1><p>${esc(what.lead)}</p></div></div>
311
- <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>`;
312
- }
313
- function pasteForm(open) {
314
- return `<details class="paste" ${open ? 'open' : ''}><summary>Tens un codi?</summary>
315
- <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>
316
- <p class="muted small">Surt al terminal, sota el codi QR. Copia’l sencer.</p></details>`;
317
- }
318
- function viewConnect() {
319
- 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>`;
320
- if (isPhone()) {
321
- return `<section class="connect">
322
- <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>
323
- <div class="panel conn-main">
324
- <ol class="stepper">
325
- <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>
326
- <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>
327
- </ol>
328
- ${cmd(CMD)}
329
- <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>
330
- ${pasteForm(false)}
331
- </div>
332
- </section>`;
338
+ const cmdBox = (c) => `<div class="cmd big"><code>${esc(c)}</code><button class="btn sm" data-copy="${esc(c)}" type="button">${svg(ICON.copy)}Copia</button></div>`;
339
+ function computerCard(m, i) {
340
+ const on = i === store.active;
341
+ const st = on ? (state.connected ? '<span class="chip ok"><i class="dot on"></i>Connectat</span>' : `<span class="chip">${esc(state.lastError ? 'Sense connexió' : 'Connectant…')}</span>`) : '';
342
+ return `<div class="card computer ${on ? 'active' : ''}">
343
+ <div class="row" style="flex-wrap:nowrap;gap:12px;align-items:flex-start"><span class="c-ico">${svg(machineIcon(m))}</span><div class="grow"><div class="row between"><b>${esc(m.name)}</b>${st}</div><div class="muted small">${esc(machineSub(m))}</div></div></div>
344
+ <div class="row c-actions">${on ? `<button class="btn ink sm" data-act="open-sessions" type="button">Sessions</button>${isPhone() ? '' : `<button class="btn sm" data-act="mobile-qr" type="button">Continua al mòbil</button>`}` : `<button class="btn ink sm" data-machine="${i}" type="button">Obre</button>`}${m.kind === 'vps' && m.via && (!on || !state.connected) ? `<button class="btn sm" data-reconnect="${i}" type="button">Torna a connectar</button>` : ''}<button class="btn ghost sm" data-forget="${i}" type="button">Oblida</button></div>
345
+ </div>`;
346
+ }
347
+ // Ajuda sempre a mà: com es fa cada cosa, i què mirar si no connecta.
348
+ function helpPanel(open) {
349
+ const q = (t, body) => `<details><summary>${t}</summary><div class="help-body">${body}</div></details>`;
350
+ return `<section class="panel help" id="help"><header><h2>Com es fa</h2></header><div class="body">
351
+ ${q('Connectar aquest ordinador', `<p>Al terminal, des de la carpeta dels teus projectes, executa <span class="mono">npx nio-cat</span>. S’obre nio.cat ja connectat. Cal Node.js 18 o més nou.</p>`)}
352
+ ${q('Fer-lo servir des del mòbil', `<p>A l’ordinador, a Ordinadors, prem <b>Continua al mòbil</b>. Surt un QR i un codi <span class="mono">nio-…</span>.</p><p>Al mòbil, obre <b>www.nio.cat/app</b> → Ordinadors → Afegeix ordinador, i escaneja el QR o enganxa el codi.</p><p>Per connectar-t’hi des de qualsevol xarxa cal <span class="mono">cloudflared</span> a l’ordinador (<span class="mono">brew install cloudflared</span>). Sense ell, el mòbil ha d’estar a la mateixa wifi.</p>`)}
353
+ ${q('Afegir un servidor (VPS) amb Claude Code', `<p>Ordinadors → Afegeix ordinador → <b>Servidor (SSH)</b> → escriu <span class="mono">usuari@servidor</span>. L’ordinador hi entra, hi engega nio i el servidor surt a la llista.</p><p>Després, prem <b>Continua al mòbil</b> amb el servidor actiu per fer-lo servir des del mòbil.</p>`)}
354
+ ${q('Què cal al servidor', `<ul><li>Entrar-hi per SSH sense contrasenya. Si no, executa <span class="mono">ssh-copy-id usuari@servidor</span> a l’ordinador.</li><li>Node.js 18 o més nou.</li><li>Claude Code instal·lat i amb sessió iniciada: entra al servidor i executa <span class="mono">claude</span> un cop.</li><li>Linux amb <span class="mono">curl</span> o <span class="mono">wget</span> (nio hi instal·la <span class="mono">cloudflared</span> sol).</li></ul>`)}
355
+ ${q('No es connecta', `<ul><li><b>«No arribo a l’ordinador»</b>: comprova que nio continua engegat al terminal.</li><li><b>El servidor ha deixat de respondre</b>: si s’ha reiniciat, l’adreça canvia. Prem <b>Torna a connectar</b> a la seva targeta.</li><li><b>Safari no connecta</b>: obre nio amb <span class="mono">npx nio-cat --local</span>, o fes servir Chrome.</li><li><b>La clau no és vàlida</b>: torna a afegir l’ordinador amb el codi nou.</li></ul>`)}
356
+ ${q('D’on es gasta', `<p>nio no fa cap crida pel seu compte. Cada agent treballa a l’ordinador o al servidor amb el teu compte (Claude, Codex, Grok…). Ollama funciona al teu ordinador i no gasta res.</p>`)}
357
+ <div class="row"><button class="btn sm" type="button" data-act="tour">Fes el tutorial</button></div>
358
+ </div></section>`;
359
+ }
360
+ function viewComputers() {
361
+ const ms = store.machines, m = machine(), info = state.info;
362
+ 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>`;
363
+ if (!ms.length) {
364
+ const steps = isPhone()
365
+ ? [['Engega nio a l’ordinador', 'Al terminal: <span class="mono">npx nio-cat</span>.'], ['Escaneja el QR', 'O escriu el codi <span class="mono">nio-…</span> que surt al terminal.'], ['Dona feina a un agent', 'Crea una sessió i segueix-la en directe.']]
366
+ : [['Engega nio', 'Al terminal: <span class="mono">npx nio-cat</span>. S’obre aquesta web ja connectada.'], ['O afegeix un servidor', 'Amb SSH: <span class="mono">usuari@servidor</span>. nio hi fa la resta.'], ['Continua al mòbil', 'Amb un QR o un codi, quan vulguis.']];
367
+ return `${head}<div class="panel empty">${bot('idle')}<h2>Connecta el teu primer ordinador</h2><ol class="stepper">${steps.map(([t, d], i) => `<li><span class="n">${i + 1}</span><div><b>${t}</b><p>${d}</p></div></li>`).join('')}</ol><button class="btn ink" data-act="add-computer" type="button">${svg(ICON.plus)}Afegeix ordinador</button></div>${helpPanel()}`;
333
368
  }
334
- return `<section class="connect">
335
- <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>
336
- <div class="choice-grid conn-main">
337
- <section class="panel choice"><header><h2>Aquest ordinador</h2></header><div class="body stack">
338
- <p class="small">Al terminal, des de la carpeta dels teus projectes:</p>${cmd(CMD)}
339
- <p class="muted small">S’obre aquest panell ja connectat. Cal Node.js 18 o més nou.</p></div></section>
340
- <section class="panel choice"><header><h2>Un altre ordinador</h2></header><div class="body stack">
341
- <p class="small">Engega nio a l’altre ordinador o a un servidor. Copia aquí el codi que mostra.</p>
342
- ${pasteForm(true)}
343
- <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')}
344
- <p class="muted small">Engega nio al servidor, obre el túnel i el panell queda connectat.</p></div></section>
369
+ const detail = m && state.connected ? `<h2 class="sec">${esc(m.name)}</h2>
370
+ <div class="grid2">
371
+ <div class="stack">
372
+ <section class="panel"><header><h2>Agents</h2><button class="btn sm ghost" data-act="refresh-agents" type="button">${svg(ICON.refresh)}Torna a detectar</button></header><div class="body">${info ? info.agents.map((a) => `<div class="agent-row"><i class="dot ${a.available ? 'on' : ''}"></i><div class="grow"><div class="row"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="muted small mono">${esc(a.available ? a.version || 'Disponible' : a.login)}</div></div></div>`).join('') : '<p class="muted">Carregant…</p>'}</div></section>
373
+ ${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>` : ''}
345
374
  </div>
346
- <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>
347
- </section>`;
375
+ <div class="stack">
376
+ <section class="panel"><header><h2>Avisos al mòbil</h2></header><div class="body stack"><p class="small">Rep un avís quan un agent et demana permís o acaba la feina.</p>${isIOS && !standalone ? '<p class="muted small">A l’iPhone, primer afegeix nio a la pantalla d’inici (Comparteix → Afegeix a la pantalla d’inici) i obre’l des d’allà.</p>' : ''}<div class="row"><button class="btn ink grow" data-act="push" type="button">Activa els avisos</button><button class="btn" data-act="push-test" type="button">Envia’n un de prova</button></div></div></section>
377
+ </div>
378
+ </div>` : '';
379
+ return `${head}<div class="computers">${ms.map(computerCard).join('')}</div>${detail}${helpPanel()}`;
380
+ }
381
+
382
+ /* ---------- Afegir un ordinador: aquest, un servidor per SSH o un codi */
383
+ async function probeLocal() {
384
+ try {
385
+ const c = new AbortController(); setTimeout(() => c.abort(), 1500);
386
+ const d = await (await fetch('http://127.0.0.1:7717/api/hello', { signal: c.signal, cache: 'no-store' })).json();
387
+ return d?.nio ? d : null;
388
+ } catch { return null; }
389
+ }
390
+ // Afegeix un servidor per SSH des de l'ordinador actiu. El progrés arriba línia a línia.
391
+ async function sshAdd(target, onStep) {
392
+ const m = machine(); if (!m) throw new Error('Primer connecta un ordinador');
393
+ const res = await fetch(`${m.url}/api/ssh/add`, { method: 'POST', headers: { Authorization: `Bearer ${m.token}`, 'content-type': 'application/json' }, body: JSON.stringify({ target }) });
394
+ if (!res.ok || !res.body) throw new Error((await res.json().catch(() => ({}))).error || 'Aquest ordinador no pot afegir servidors. Actualitza nio.');
395
+ const reader = res.body.getReader(), dec = new TextDecoder(); let buf = '', last = null;
396
+ for (;;) {
397
+ const { value, done } = await reader.read(); if (done) break;
398
+ buf += dec.decode(value, { stream: true }); let i;
399
+ while ((i = buf.indexOf('\n')) >= 0) {
400
+ const o = JSON.parse(buf.slice(0, i)); buf = buf.slice(i + 1);
401
+ if (o.step) onStep(o.step); else last = o;
402
+ }
403
+ }
404
+ if (!last || last.error) throw new Error(last?.error || 'S’ha tallat la connexió');
405
+ return last;
406
+ }
407
+ async function startScan(video, onText) {
408
+ const det = new BarcodeDetector({ formats: ['qr_code'] });
409
+ const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
410
+ video.srcObject = stream; await video.play(); let alive = true;
411
+ (async () => { while (alive) { try { const r = await det.detect(video); if (r[0]) onText(r[0].rawValue); } catch { /* quadre sense QR */ } await sleep(250); } })();
412
+ return { stop() { alive = false; stream.getTracks().forEach((t) => t.stop()); } };
413
+ }
414
+ function addComputerSheet(tab, opts = {}) {
415
+ const phone = isPhone(), canScan = 'BarcodeDetector' in window && !!navigator.mediaDevices?.getUserMedia;
416
+ const TABS = phone ? [['code', 'Codi o QR'], ['vps', 'Servidor (SSH)']] : [['local', 'Aquest ordinador'], ['vps', 'Servidor (SSH)'], ['code', 'Tinc un codi']];
417
+ let cur = TABS.some((t) => t[0] === tab) ? tab : TABS[0][0], poll = 0, scan = null, shown = '', hosts = [];
418
+ openSheet('', (sh) => {
419
+ sh.innerHTML = `<div class="wiz-head"><div class="grab"></div><div class="row between" style="flex-wrap:nowrap"><h2>Afegeix un ordinador</h2><button class="icon-btn" type="button" data-close aria-label="Tanca">×</button></div><div class="seg" id="addTabs" role="group">${TABS.map(([k, l]) => `<button type="button" data-tab="${k}">${l}</button>`).join('')}</div></div><div class="wiz-body" id="addBody"></div>`;
420
+ const body = $('#addBody', sh);
421
+ const stop = () => { clearTimeout(poll); scan?.stop(); scan = null; shown = ''; };
422
+ const set = (key, html) => { if (key === shown) return false; shown = key; body.innerHTML = html; return true; };
423
+ const draw = () => {
424
+ stop(); if (cur === 'vps' && !hosts.length && machine()) api('/api/ssh/hosts').then((h) => { hosts = h; if (cur === 'vps' && shown === 'vps') { shown = ''; vps(); } }).catch(() => {});
425
+ $$('#addTabs button', sh).forEach((b) => b.setAttribute('aria-pressed', b.dataset.tab === cur));
426
+ body.scrollTop = 0;
427
+ if (cur === 'local') local(); else if (cur === 'vps') vps(); else code();
428
+ };
429
+ const local = async () => {
430
+ set('wait', '<p class="muted small">Mirant si nio funciona en aquest ordinador…</p>');
431
+ const d = await probeLocal(); if (cur !== 'local') return;
432
+ const saved = d && store.machines.findIndex((x) => x.id === d.id);
433
+ if (d && saved >= 0) set('saved', `<p class="wiz-q">Aquest ordinador ja és a la llista</p><p class="muted small">${esc(d.name)}</p><div class="row"><button class="btn ink" type="button" data-open-saved="${saved}">Obre</button></div>`);
434
+ else if (d) set('found', `<p class="wiz-q">nio funciona en aquest ordinador</p><p class="muted small">Aquest navegador encara no hi té accés. Torna a engegar nio perquè obri el panell ja connectat:</p>${cmdBox('npx nio-cat')}`);
435
+ else set('none', `<p class="wiz-q">Engega nio en aquest ordinador</p><p class="muted small">Al terminal, des de la carpeta dels teus projectes:</p>${cmdBox('npx nio-cat')}<p class="muted small">S’obrirà aquest panell ja connectat. Cal Node.js 18 o més nou.</p>`);
436
+ if (saved < 0 || !d) poll = setTimeout(local, 3000);
437
+ };
438
+ const runSsh = async (target) => {
439
+ const steps = $('#sshSteps', sh), btn = $('#sshForm button', sh); if (!steps) return; btn.disabled = true; steps.innerHTML = '';
440
+ const add = (t) => { $$('li.on', steps).forEach((l) => { l.className = 'ok'; }); steps.insertAdjacentHTML('beforeend', `<li class="on">${esc(t)}…</li>`); };
441
+ try {
442
+ const r = await sshAdd(target, add);
443
+ LS.set('nio.lastSsh', target);
444
+ const old = store.machines.findIndex((x) => x.via === target && x.kind === 'vps');
445
+ if (old >= 0) { const ms = store.machines; ms.splice(old, 1); store.machines = ms; store.active = Math.min(store.active, Math.max(0, ms.length - 1)); }
446
+ addMachine({ url: r.url, token: r.token, name: r.host, kind: 'vps', via: target });
447
+ shown = 'vps-ok';
448
+ body.innerHTML = `<p class="wiz-q">${esc(r.host)} és a punt</p>${r.claude ? '<p class="muted small">Ja el pots fer servir: crea-hi una sessió amb Claude Code.</p>' : '<p class="muted small">Claude Code no hi és. Entra al servidor, instal·la’l i inicia sessió amb <span class="mono">claude</span>.</p>'}<div class="row"><a class="btn ink" href="#/sessions" data-close>Obre sessions</a><button class="btn" type="button" data-act="mobile-qr">Continua al mòbil</button></div>`;
449
+ } catch (err) { $$('li.on', steps).forEach((l) => { l.className = 'err'; }); steps.insertAdjacentHTML('beforeend', `<li class="err">${esc(err.message)}</li>`); btn.disabled = false; }
450
+ };
451
+ const vps = () => {
452
+ const m = machine();
453
+ if (!m) return set('vps-no', `<p class="wiz-q">Primer, un ordinador</p><p class="muted small">El servidor s’afegeix des d’un ordinador que ja tinguis connectat, perquè és ell qui hi entra per SSH.</p>${phone ? '' : '<button class="btn" type="button" data-tab="local">Afegeix aquest ordinador</button>'}`);
454
+ set('vps', `<p class="wiz-q">Un servidor amb Claude Code</p>
455
+ <p class="muted small">${esc(m.name)} hi entra per SSH, hi engega nio amb un túnel segur i el deixa llest per al mòbil.</p>
456
+ ${hosts.length ? `<div class="row">${hosts.map((h) => `<button class="chip" type="button" data-host="${esc(h.target)}" title="${esc(h.host)}">${esc(h.target)}</button>`).join('')}</div>` : ''}
457
+ <form class="pair-form" id="sshForm"><input type="text" id="sshIn" value="${esc(opts.target || LS.get('nio.lastSsh', ''))}" placeholder="usuari@servidor" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Usuari i servidor"><button class="btn ink" type="submit">Afegeix</button></form>
458
+ <ul class="steps" id="sshSteps"></ul>
459
+ <p class="muted small">Cal entrar-hi sense contrasenya (clau SSH) i que el servidor tingui Node.js 18 o més nou. Claude Code hi ha d’estar instal·lat i amb sessió iniciada.</p>`);
460
+ };
461
+ const code = () => {
462
+ set('code', `<p class="wiz-q">${phone ? 'Escaneja o escriu el codi' : 'Escriu el codi'}</p>
463
+ <form class="pair-form" id="pairForm"><input type="text" id="pairInput" placeholder="nio-…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Codi de connexió"><button class="btn ink" type="submit">Connecta</button></form>
464
+ ${canScan ? `<button class="btn" type="button" data-scan>${svg(ICON.scan)}Escaneja un QR</button><video class="scan" id="scanVideo" playsinline muted hidden></video>` : phone ? '<p class="muted small">Per escanejar, obre la càmera del mòbil i apunta al QR: s’obrirà nio.</p>' : ''}
465
+ <p class="muted small">El codi surt al terminal, sota el QR, o a «Continua al mòbil».</p>`);
466
+ };
467
+ sh.onclick = async (e) => {
468
+ const t = e.target.closest('[data-tab]'); if (t) { cur = t.dataset.tab; return draw(); }
469
+ const hb = e.target.closest('[data-host]'); if (hb) { $('#sshIn', sh).value = hb.dataset.host; return $('#sshForm', sh).requestSubmit(); }
470
+ const os = e.target.closest('[data-open-saved]'); if (os) { switchMachine(+os.dataset.openSaved); return go('#/sessions'); }
471
+ if (e.target.closest('[data-close]')) { stop(); return closeSheet(); }
472
+ if (e.target.closest('[data-scan]')) {
473
+ const v = $('#scanVideo', sh); v.hidden = false;
474
+ try { scan = await startScan(v, (text) => { const pair = pairFrom(text); if (pair) { stop(); closeSheet(); addMachine(pair); toast('Connectant…'); go('#/sessions'); } }); }
475
+ catch { v.hidden = true; toast('No puc obrir la càmera. Escriu el codi.'); }
476
+ }
477
+ };
478
+ sh.onsubmit = async (e) => {
479
+ if (e.target.id !== 'sshForm') return;
480
+ e.preventDefault(); e.stopPropagation();
481
+ runSsh($('#sshIn', sh).value.trim());
482
+ };
483
+ draw();
484
+ if (opts.auto && cur === 'vps') setTimeout(() => runSsh(opts.target), 50);
485
+ }, 'wiz');
486
+ }
487
+ function machinesSheet() {
488
+ const ms = store.machines;
489
+ openSheet(`<h2>Ordinadors</h2>${ms.length ? `<div class="stack">${ms.map((x, i) => `<button class="card row" type="button" data-machine="${i}" style="flex-wrap:nowrap"><span class="c-ico">${svg(machineIcon(x))}</span><span class="grow"><b>${esc(x.name)}</b><div class="muted small">${esc(machineSub(x))}</div></span>${i === store.active ? `<span class="chip ok"><i class="dot ${state.connected ? 'on' : ''}"></i>Actiu</span>` : ''}</button>`).join('')}</div>` : '<p class="muted small">Encara no hi ha cap ordinador.</p>'}
490
+ <div class="row between sheet-foot">${ms.length ? '<a class="btn ghost" href="#/ordinadors" data-close>Gestiona</a>' : '<button class="btn ghost" type="button" data-close>Tanca</button>'}<button class="btn ink" type="button" data-act="add-computer">${svg(ICON.plus)}Afegeix ordinador</button></div>`, (sh) => { $$('[data-close]', sh).forEach((b) => (b.onclick = closeSheet)); });
491
+ }
492
+ function moreSheet() {
493
+ openSheet(`<h2>Més</h2><div class="list">${['models', 'mobil', 'pissarra'].map((k) => `<a class="card row" href="${HASH[k]}" style="gap:12px">${svg(VIEWS[k].icon)}<b>${VIEWS[k].label}</b></a>`).join('')}<a class="card row" href="#/ordinadors" data-act="help" style="gap:12px"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.1"/></svg><b>Com es fa</b></a><button class="card row" type="button" data-act="tour" style="gap:12px"><svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.6M12 17.2v.1"/></svg><b>Tutorial</b></button></div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
348
494
  }
349
495
 
350
496
  /* ---------- Sessions */
@@ -361,7 +507,7 @@ function viewSessions() {
361
507
  const hint = mobileHint();
362
508
  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}`;
363
509
  if (!state.connected && !list.length) {
364
- return `${head}<div class="panel empty">${bot(state.lastError ? 'sleep' : 'work')}<h2>${esc(state.lastError || 'Connectant amb l’ordinador…')}</h2>${state.lastError ? `<p>Comprova que nio continua engegat a l’ordinador i que el túnel està obert.</p><button class="btn" data-act="retry" type="button">${svg(ICON.refresh)}Torna-ho a provar</button>` : ''}</div>`;
510
+ 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>`;
365
511
  }
366
512
  return `${head}${list.length ? `<div class="list">${list.map((s) => `
367
513
  <a class="card session-card" href="#/s/${esc(s.id)}">
@@ -528,23 +674,103 @@ function renderTyping() {
528
674
  if (!busy) $$('.msg-ai.cursor', feed).forEach((n) => n.classList.remove('cursor'));
529
675
  const hb = $('#headBot .bot'); if (hb) hb.dataset.mood = sessionMood(s);
530
676
  }
677
+ /* ---------- Comandes amb «/» (les de nio i les de l'agent) */
678
+ const NIO_CMDS = [
679
+ { name: 'mode', hint: 'Canvia els permisos', args: [['llegir', 'Només llegir', 'l'], ['preguntar', 'Demana permís', 'p'], ['tot', 'Via lliure', 't']] },
680
+ { name: 'model', hint: 'Canvia de model' },
681
+ { name: 'stop', hint: 'Atura l’agent' },
682
+ { name: 'nova', hint: 'Nova sessió amb el mateix agent i carpeta' },
683
+ { name: 'git', hint: 'Git i GitHub: commit i PR' },
684
+ { name: 'nom', hint: 'Posa nom a la sessió', free: true },
685
+ { name: 'opcions', hint: 'Opcions de la sessió' },
686
+ { name: 'ajuda', hint: 'Llista de comandes' }
687
+ ];
688
+ const cmdCache = new Map();
689
+ async function loadCmds(id) {
690
+ if (cmdCache.has(id) || !machine()) return;
691
+ cmdCache.set(id, []);
692
+ try { cmdCache.set(id, await api(`/api/sessions/${id}/commands`)); } catch { /* sense comandes de l'agent */ }
693
+ }
694
+ const agentCmds = (id) => cmdCache.get(id) || [];
695
+ function pickMode(arg) {
696
+ const a = String(arg || '').trim().toLowerCase(); if (!a) return null;
697
+ const m = NIO_CMDS[0].args.find(([k, , al]) => k === a || al === a || k.startsWith(a) || (a === 'lliure' && k === 'tot'));
698
+ return m ? m[0] : null;
699
+ }
700
+ async function runCmd(s, name, arg) {
701
+ const patch = async (body, msg) => { try { const sum = await api(`/api/sessions/${s.id}`, { method: 'PATCH', body }); state.sessions.set(s.id, sum); renderSessionHead(); if (msg) toast(msg); } catch (e) { toast(e.message, 4200); } };
702
+ switch (name) {
703
+ case 'mode': { const m = pickMode(arg); if (!arg) return modeSheet(s); if (!m) return toast('Els permisos són: llegir (l), preguntar (p) o tot (t)'); return patch({ mode: m }, `Permisos: ${MODES[m].label}`); }
704
+ case 'model': return arg ? patch({ model: arg }, `Model: ${arg}`) : modelSheet(s);
705
+ case 'stop': try { await api(`/api/sessions/${s.id}/stop`, { method: 'POST' }); toast('Aturat'); } catch (e) { toast(e.message); } return;
706
+ case 'nova': return newSessionSheet({ agent: s.agent, cwd: s.cwd, mode: s.mode });
707
+ case 'git': return gitSheet(s.id);
708
+ case 'nom': return arg ? patch({ title: arg }, 'Nom desat') : toast('Escriu el nom: /nom Arregla els tests');
709
+ case 'opcions': return sessionOptions(s.id);
710
+ case 'ajuda': return helpCmdsSheet(s);
711
+ }
712
+ }
713
+ function helpCmdsSheet(s) {
714
+ const ac = agentCmds(s.id);
715
+ 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; });
716
+ }
717
+ // Llista de suggeriments segons el que hi ha escrit: «/», «/mo», «/mode l».
718
+ function slashItems(s, v) {
719
+ const m = v.match(/^\/([\w:.-]*)$/), a = v.match(/^\/mode\s+(\S*)$/i);
720
+ if (a) { const q = a[1].toLowerCase(); return NIO_CMDS[0].args.filter(([k, , al]) => !q || k.startsWith(q) || al === q).map(([k, h, al]) => ({ fill: `/mode ${k}`, run: ['mode', k], label: `/mode ${k}`, hint: `${h} · ${al}`, tag: 'nio' })); }
721
+ if (!m) return [];
722
+ const q = m[1].toLowerCase();
723
+ const nio = NIO_CMDS.map((c) => ({ ...c, tag: 'nio' }));
724
+ const ag = agentCmds(s.id).map((c) => ({ name: c.name, hint: c.hint, tag: AGENT_NAMES[s.agent] || s.agent, agent: true }));
725
+ 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));
726
+ return [...starts, ...has].slice(0, 40).map((c) => ({ ...c, label: `/${c.name}` }));
727
+ }
531
728
  function renderComposer() {
532
729
  const box = $('#composer'); if (!box) return;
533
730
  const s = state.sessions.get(state.route.id), busy = !!s && (s.state === 'running' || s.state === 'waiting');
534
731
  const prev = $('textarea', box)?.value || '';
535
732
  if (box.dataset.busy === String(busy) && $('textarea', box)) return;
536
733
  box.dataset.busy = String(busy);
537
- box.innerHTML = `<div class="inner"><textarea rows="1" id="msg" placeholder="${busy ? 'L’agent està treballant…' : 'Escriu a l’agent…'}" enterkeyhint="send" aria-label="Missatge"></textarea><button class="send ${busy ? 'stop' : ''}" id="sendBtn" type="button" aria-label="${busy ? 'Atura l’agent' : 'Envia'}">${svg(busy ? ICON.stop : ICON.send)}</button></div>`;
734
+ if (s) loadCmds(s.id).then(() => { if (state.route.id === s.id) drawSlash(); });
735
+ box.innerHTML = `<div class="slash" id="slash" role="listbox" hidden></div><div class="inner"><textarea rows="1" id="msg" placeholder="${busy ? 'L’agent està treballant…' : 'Escriu a l’agent… (/ per a comandes)'}" enterkeyhint="send" aria-label="Missatge" aria-controls="slash"></textarea><button class="send ${busy ? 'stop' : ''}" id="sendBtn" type="button" aria-label="${busy ? 'Atura l’agent' : 'Envia'}">${svg(busy ? ICON.stop : ICON.send)}</button></div>`;
538
736
  const ta = $('#msg'); ta.value = prev;
539
737
  const grow = () => autogrow(ta);
540
- grow(); ta.addEventListener('input', grow);
738
+ let items = [], sel = 0;
739
+ const pop = $('#slash');
740
+ function drawSlash() {
741
+ const cur = state.sessions.get(state.route.id); if (!cur || !pop.isConnected) return;
742
+ items = ta.value.includes('\n') ? [] : slashItems(cur, ta.value);
743
+ pop.hidden = !items.length; if (!items.length) return;
744
+ sel = Math.min(sel, items.length - 1);
745
+ pop.innerHTML = items.map((c, i) => `<button type="button" role="option" data-i="${i}" aria-selected="${i === sel}"><span class="n mono">${esc(c.label)}</span><span class="h">${esc(c.hint || '')}</span><span class="t ${c.agent ? 'ag' : ''}">${esc(c.tag)}</span></button>`).join('');
746
+ $('[aria-selected="true"]', pop)?.scrollIntoView({ block: 'nearest' });
747
+ }
748
+ const pick = (i) => {
749
+ const c = items[i]; if (!c) return; const cur = state.sessions.get(state.route.id);
750
+ if (c.run) { ta.value = ''; grow(); drawSlash(); return runCmd(cur, ...c.run); }
751
+ if (c.args || c.free || c.agent) { ta.value = `/${c.name} `; grow(); sel = 0; drawSlash(); ta.focus(); return; }
752
+ ta.value = ''; grow(); drawSlash(); runCmd(cur, c.name, '');
753
+ };
754
+ ta.addEventListener('input', () => { grow(); sel = 0; drawSlash(); });
755
+ pop.addEventListener('pointerdown', (e) => { const b = e.target.closest('[data-i]'); if (b) { e.preventDefault(); pick(+b.dataset.i); } });
541
756
  const send = async () => {
542
757
  const text = ta.value.trim(); if (!text || !s) return;
543
- ta.value = ''; grow();
758
+ const c = text.match(/^\/([\w:.-]+)(?:\s+([\s\S]*))?$/), nio = c && NIO_CMDS.find((x) => x.name === c[1].toLowerCase());
759
+ ta.value = ''; grow(); drawSlash();
760
+ if (nio) return runCmd(s, nio.name, (c[2] || '').trim());
544
761
  try { await api(`/api/sessions/${s.id}/message`, { method: 'POST', body: { text } }); }
545
762
  catch (e) { ta.value = text; grow(); toast(e.message, 4200); }
546
763
  };
547
- ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !isTouch && !e.isComposing) { e.preventDefault(); send(); } });
764
+ ta.addEventListener('keydown', (e) => {
765
+ if (items.length && !pop.hidden) {
766
+ if (e.key === 'ArrowDown') { e.preventDefault(); sel = (sel + 1) % items.length; return drawSlash(); }
767
+ if (e.key === 'ArrowUp') { e.preventDefault(); sel = (sel - 1 + items.length) % items.length; return drawSlash(); }
768
+ if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey && !isTouch && !e.isComposing && !/^\/\S+\s/.test(ta.value))) { e.preventDefault(); return pick(sel); }
769
+ if (e.key === 'Escape') { e.preventDefault(); pop.hidden = true; items = []; return; }
770
+ }
771
+ if (e.key === 'Enter' && !e.shiftKey && !isTouch && !e.isComposing) { e.preventDefault(); send(); }
772
+ });
773
+ grow(); drawSlash();
548
774
  $('#sendBtn').onclick = async () => { if (busy) { try { await api(`/api/sessions/${s.id}/stop`, { method: 'POST' }); } catch (e) { toast(e.message); } } else send(); };
549
775
  }
550
776
 
@@ -573,7 +799,11 @@ async function newSessionSheet(preset = {}) {
573
799
  let projects = [];
574
800
  try { projects = await api('/api/projects'); } catch (e) { return toast(e.message); }
575
801
  const first = agents.find((a) => a.available);
576
- const sel = { agent: first?.id || '', cwd: projects.find((p) => !p.root)?.path || projects[0]?.path || '', mode: 'preguntar', model: '', text: '', ...preset };
802
+ // Continuïtat: l'última tria d'aquest ordinador (agent, carpeta, permisos) i les carpetes recents.
803
+ const lastKey = `nio.last.${machine()?.id || machine()?.url}`, last = LS.get(lastKey, {});
804
+ const lastAgent = agents.find((a) => a.id === last.agent && a.available);
805
+ const sel = { agent: lastAgent?.id || first?.id || '', cwd: last.cwd || projects.find((p) => !p.root)?.path || projects[0]?.path || '', mode: last.mode || 'preguntar', model: '', text: '', ...preset };
806
+ const recents = (last.recent || []).filter((p) => p);
577
807
  const STEPS = ['Agent', 'Carpeta', 'Opcions', 'Tasca'];
578
808
  let step = preset.agent ? 1 : 0, fsd = null, gh = null, models = null;
579
809
  const agentOf = () => agents.find((a) => a.id === sel.agent);
@@ -626,7 +856,7 @@ async function newSessionSheet(preset = {}) {
626
856
  () => `<p class="wiz-q">Quin agent vols fer servir?</p><div class="picker">${agents.map((a) => `<button type="button" class="pick ${a.available ? '' : 'off'}" data-agent-id="${esc(a.id)}" aria-pressed="${a.id === sel.agent}"><div class="row between"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="sub">${esc(a.available ? a.version || 'Disponible' : 'No instal·lat')}</div></button>`).join('')}</div>`,
627
857
  () => {
628
858
  const crumbs = fsd ? fsd.path.split(/[\\/]/).filter(Boolean) : [];
629
- const quick = projects.slice(0, 8).map((p) => `<button type="button" class="chip" data-fs="${esc(p.path)}">${esc(p.name)}</button>`).join('');
859
+ 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('');
630
860
  return `<p class="wiz-q">A quina carpeta ha de treballar?</p>
631
861
  <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>
632
862
  <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>
@@ -650,7 +880,7 @@ async function newSessionSheet(preset = {}) {
650
880
  const start = async () => {
651
881
  if (!sel.text.trim()) { $('#firstMsg', sh)?.focus(); return toast('Explica a l’agent què ha de fer'); }
652
882
  const btn = $('[data-start]', sh); btn.disabled = true;
653
- 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}`); }
883
+ try { const { text, ...rest } = sel; const s = await api('/api/sessions', { method: 'POST', body: { ...rest, text: text.trim() } }); state.sessions.set(s.id, s); LS.set(lastKey, { agent: sel.agent, cwd: sel.cwd, mode: sel.mode, recent: [sel.cwd, ...recents.filter((p) => p !== sel.cwd)].slice(0, 6) }); closeSheet(); go(`#/s/${s.id}`); }
654
884
  catch (e) { toast(e.message, 4200); btn.disabled = false; }
655
885
  };
656
886
  sh.onclick = (e) => {
@@ -715,24 +945,10 @@ function modeSheet(s) {
715
945
  /* ---------- Ordinadors i mòbil */
716
946
  const isLocalMachine = () => { const m = machine(); try { return !!m && ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(m.url).hostname); } catch { return false; } };
717
947
  function mobileHint() {
718
- if (!isLocalMachine() || !state.connected || LS.get('nio.mobHint.off', false)) return '';
948
+ const m = machine(), key = `nio.mobHint.${m?.id || m?.url}`;
949
+ if (!m || isPhone() || !state.connected || LS.get(key, false)) return '';
719
950
  return `<div class="panel hint"><div class="row between" style="flex-wrap:wrap;gap:12px"><div><b>Vols fer-lo servir al mòbil?</b><p class="muted small" style="margin-top:2px">Escaneja un codi i continues des del telèfon, amb les mateixes sessions.</p></div><div class="row"><button class="btn ghost" type="button" data-act="mobile-hint-off">Ara no</button><button class="btn ink" type="button" data-act="mobile-qr">Mostra el QR</button></div></div></div>`;
720
951
  }
721
- function machinesSheet() {
722
- const ms = store.machines;
723
- 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('')}
724
- <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; });
725
- }
726
- function addMachineSheet() {
727
- const 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>`;
728
- openSheet(`<h2>Connecta un altre ordinador</h2>
729
- <div class="stack-lg">
730
- <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>
731
- <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>
732
- <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>
733
- <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>
734
- </div>`);
735
- }
736
952
  let qrTimer = 0;
737
953
  function mobileQrSheet() {
738
954
  clearTimeout(qrTimer);
@@ -741,11 +957,11 @@ function mobileQrSheet() {
741
957
  let r; try { r = await api('/api/pair'); } catch (e) { box.innerHTML = `<p class="muted small">${esc(e.message)}</p>`; return; }
742
958
  if (!$('#qrBox')) return;
743
959
  if (!r.link) {
744
- box.innerHTML = `<p class="muted small">Encara no hi ha cap adreça per al mòbil.${n < 8 ? ' Estic esperant el túnel…' : ''}</p><p class="muted small">Engega nio amb <span class="mono">--lan</span> (mateixa wifi) o instal·la <span class="mono">cloudflared</span> per connectar-t’hi des de qualsevol lloc.</p>`;
960
+ box.innerHTML = n < 8 ? '<p class="muted small">Esperant el túnel…</p>' : `<p class="wiz-q">Falta una adreça per al mòbil</p><p class="muted small">Per connectar-t’hi des de qualsevol xarxa, instal·la <span class="mono">cloudflared</span> a l’ordinador i torna a engegar nio:</p>${cmdBox('brew install cloudflared')}<p class="muted small">Sense ell, el mòbil només es connecta per la mateixa wifi: engega nio amb <span class="mono">npx nio-cat --lan</span>.</p>`;
745
961
  if (n < 8) qrTimer = setTimeout(() => paint(n + 1), 2000);
746
962
  return;
747
963
  }
748
- 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>`;
964
+ 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">Al mòbil: www.nio.cat/app → Ordinadors → Afegeix ordinador. Escaneja el QR o enganxa el codi.</p>`;
749
965
  };
750
966
  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; });
751
967
  paint();
@@ -793,25 +1009,6 @@ function sessionOptions(id) {
793
1009
  });
794
1010
  }
795
1011
 
796
- /* ---------- Ordinador */
797
- function viewMachine() {
798
- const m = machine(), info = state.info, ms = store.machines;
799
- 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>
800
- <div class="grid2">
801
- <div class="stack">
802
- <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>
803
- ${info?.roots?.length ? `<section class="panel"><header><h2>Carpetes compartides</h2></header><div class="body mono small">${info.roots.map(esc).join('<br>')}</div></section>` : ''}
804
- </div>
805
- <div class="stack">
806
- <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>
807
- ${'<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>'}
808
- <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>
809
- <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('')}
810
- <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>
811
- </div>
812
- </div>`;
813
- }
814
-
815
1012
  /* ---------- Models d'Ollama */
816
1013
  const OLLAMA_PICKS = [
817
1014
  { name: 'qwen3:4b', size: '2,5 GB', note: 'Ràpid i bo per a gairebé tot.' },
@@ -1030,7 +1227,7 @@ async function sendLocal() {
1030
1227
 
1031
1228
  /* ---------------------------------------------------------------- accions */
1032
1229
  document.addEventListener('click', async (e) => {
1033
- const a = e.target.closest('[data-act], [data-copy], [data-decide], [data-machine], [data-lm]');
1230
+ const a = e.target.closest('[data-act], [data-copy], [data-decide], [data-machine], [data-forget], [data-reconnect], [data-lm]');
1034
1231
  if (!a) return;
1035
1232
  if (a.dataset.copy) {
1036
1233
  try { await navigator.clipboard.writeText(a.dataset.copy); } catch { return toast(a.dataset.copy); }
@@ -1042,7 +1239,9 @@ document.addEventListener('click', async (e) => {
1042
1239
  return;
1043
1240
  }
1044
1241
  if (a.dataset.decide) return decide(a.closest('[data-approval]'), a.dataset.decide);
1045
- if (a.dataset.machine != null) { closeSheet(); store.active = Number(a.dataset.machine); state.ollama = null; state.sessions.clear(); state.approvals.clear(); state.events.clear(); state.loaded.clear(); state.connected = false; state.info = null; connect(); return render(); }
1242
+ if (a.dataset.machine != null) return switchMachine(Number(a.dataset.machine));
1243
+ if (a.dataset.reconnect != null) { const m = store.machines[Number(a.dataset.reconnect)]; return addComputerSheet('vps', { target: m.via, auto: true }); }
1244
+ if (a.dataset.forget != null) { if (confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) forgetMachine(Number(a.dataset.forget)); return; }
1046
1245
  if (a.dataset.lm) { if (local.loading) return; LS.set('nio.localModel', a.dataset.lm); return render(); }
1047
1246
  switch (a.dataset.act) {
1048
1247
  case 'new': return newSessionSheet();
@@ -1058,17 +1257,14 @@ document.addEventListener('click', async (e) => {
1058
1257
  case 'refresh-agents': try { state.info = await api('/api/info?refresh=1'); render(); toast('Agents actualitzats'); } catch (err) { toast(err.message); } return;
1059
1258
  case 'push': return enablePush();
1060
1259
  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;
1061
- case 'add-machine': return addMachineSheet();
1260
+ case 'add-computer': return addComputerSheet();
1261
+ case 'open-sessions': return go('#/sessions');
1262
+ case 'help': go('#/ordinadors'); setTimeout(() => { const h = $('#help'); h?.scrollIntoView({ block: 'start' }); $('details', h)?.setAttribute('open', ''); }, 60); return;
1263
+ case 'more': return moreSheet();
1062
1264
  case 'machines': return machinesSheet();
1063
1265
  case 'mobile-qr': return mobileQrSheet();
1064
- case 'mobile-hint-off': LS.set('nio.mobHint.off', true); return render();
1266
+ case 'mobile-hint-off': { const m = machine(); LS.set(`nio.mobHint.${m?.id || m?.url}`, true); return render(); }
1065
1267
  case 'git': return gitSheet(state.route.id);
1066
- case 'forget': {
1067
- if (!confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) return;
1068
- const list = store.machines; list.splice(store.active, 1); store.machines = list; store.active = 0;
1069
- streamCtrl?.abort(); state.connected = false; state.info = null; state.sessions.clear(); state.approvals.clear();
1070
- connect(); return render();
1071
- }
1072
1268
  case 'lm-load': return loadLocal(local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id));
1073
1269
  case 'lm-change': await local.engine?.unload?.(); local.engine = null; local.model = null; return render();
1074
1270
  case 'lm-clear': local.messages = []; return render();
@@ -1091,7 +1287,9 @@ let palSel = 0;
1091
1287
  function commands() {
1092
1288
  const c = Object.entries(VIEWS).map(([k, v]) => ({ label: `Ves a ${v.label}`, hint: 'Navega', run: () => go(HASH[k]) }));
1093
1289
  if (machine()) c.push({ label: 'Nova sessió', hint: 'Crea', run: newSessionSheet });
1290
+ c.push({ label: 'Afegeix un ordinador', hint: 'Crea', run: () => addComputerSheet() });
1094
1291
  [...state.sessions.values()].slice(0, 20).forEach((s) => c.push({ label: s.title || 'Sessió nova', hint: AGENT_NAMES[s.agent] || s.agent, run: () => go(`#/s/${s.id}`) }));
1292
+ c.push({ label: 'Com es fa (ajuda)', hint: 'Ajuda', run: () => { go('#/ordinadors'); setTimeout(() => $('#help')?.scrollIntoView({ block: 'start' }), 60); } });
1095
1293
  c.push({ label: 'Fes el tutorial', hint: 'Ajuda', run: tourStart });
1096
1294
  return c;
1097
1295
  }
@@ -1139,42 +1337,22 @@ $('#scrim').addEventListener('click', () => setNav(false));
1139
1337
  $('#side').addEventListener('click', (e) => { if (e.target.closest('a, button')) setNav(false); });
1140
1338
  addEventListener('keydown', (e) => { if (e.key === 'Escape' && document.body.classList.contains('nav-open')) setNav(false); });
1141
1339
  mq.addEventListener('change', () => setNav(false));
1142
- (() => {
1143
- const side = $('#side'); let x0 = null, y0 = 0, dx = 0, mode = null;
1144
- addEventListener('touchstart', (e) => {
1145
- if (!mq.matches || $('#sheet').open || state.route.name === 'pissarra') return;
1146
- const t = e.touches[0], open = document.body.classList.contains('nav-open');
1147
- if (open || t.clientX < 22) { x0 = t.clientX; y0 = t.clientY; dx = 0; mode = open ? 'close' : 'open'; }
1148
- }, { passive: true });
1149
- addEventListener('touchmove', (e) => {
1150
- if (x0 == null) return;
1151
- const t = e.touches[0]; dx = t.clientX - x0;
1152
- if (Math.abs(t.clientY - y0) > Math.abs(dx) && Math.abs(dx) < 12) { x0 = null; return; }
1153
- const w = side.offsetWidth, pos = mode === 'open' ? Math.min(0, -w + Math.max(0, dx)) : Math.min(0, dx);
1154
- side.classList.add('dragging'); side.style.transform = `translateX(${pos}px)`;
1155
- }, { passive: true });
1156
- addEventListener('touchend', () => {
1157
- if (x0 == null) return;
1158
- side.classList.remove('dragging'); side.style.transform = '';
1159
- if (mode === 'open' && dx > 60) setNav(true); else if (mode === 'close' && dx < -60) setNav(false);
1160
- x0 = null;
1161
- });
1162
- })();
1163
-
1164
1340
  /* ---------------------------------------------------------------- tutorial */
1165
1341
  const TOUR_KEY = 'nio.tour.v2';
1166
1342
  const T = Object.assign({ on: false, i: 0, seen: false }, LS.get(TOUR_KEY, {}));
1167
1343
  const saveTour = () => LS.set(TOUR_KEY, { on: T.on, i: T.i, seen: T.seen });
1168
1344
  const TSTEPS = [
1169
- { 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' },
1170
- { 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.') },
1171
- { 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.' },
1172
- { 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.' },
1173
- { 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.' },
1345
+ { center: true, mood: 'happy', title: 'Hola, sóc en Nio.', text: 'Faig de pont entre el teu mòbil i els agents d’IA (Claude Code, Codex…) del teu ordinador o d’un servidor. En un minut t’ensenyo com es fa.', next: 'Comencem' },
1346
+ { route: 'ordinadors', target: '#addComputer', m: '#addComputer', title: '1. Afegeix un ordinador', text: 'Hi ha tres maneres: aquest ordinador (<span class="mono">npx nio-cat</span>), un servidor per SSH (<span class="mono">usuari@servidor</span>) o un codi. nio fa la resta.' },
1347
+ { route: 'ordinadors', skipIf: () => !machine(), target: '.card.computer.active .c-actions', m: '.card.computer.active .c-actions', title: '2. Continua al mòbil', text: 'A la targeta de l’ordinador prem «Continua al mòbil»: surt un QR i un codi. Al mòbil, obre www.nio.cat/app, Ordinadors, i escaneja el QR o enganxa el codi.' },
1348
+ { route: 'sessions', skipIf: () => !machine(), target: '.top [data-act=new]', m: '.top [data-act=new]', title: '3. Dona feina a un agent', text: 'Una sessió és una conversa amb un agent dins d’una carpeta. Tries l’agent, la carpeta (qualsevol de l’ordinador), els permisos i li expliques què ha de fer.' },
1349
+ { route: 'approvals', skipIf: () => !machine(), target: '.side nav a[href="#/aprovacions"]', m: '#tabbar a[href="#/aprovacions"]', title: '4. Tu decideixes', text: 'En mode «Demana permís», abans d’editar un fitxer o executar una ordre, l’agent t’ho pregunta aquí. Si actives els avisos, t’arriba al mòbil.' },
1350
+ { route: 'models', skipIf: () => !machine(), target: '.side nav a[href="#/models"]', m: '#tabbar [data-act=more]', title: 'Models al teu ordinador', text: 'Si tens Ollama, aquí descarregues models i veus quins tens. Res no surt de casa i no gasta res.' },
1351
+ { 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.' },
1174
1352
  { 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.' },
1175
- { route: 'maquina', target: '.side nav a[href="#/ordinador"]', m: '.page .top h1, .connect-head h1', title: 'El teu ordinador', text: 'Aquí veus quins agents hi ha instal·lats, actives els avisos i connectes més ordinadors.' },
1176
- { target: '[data-act=palette]', m: '#burger', title: () => (mq.matches ? 'El menú' : 'Un últim truc'), text: () => (mq.matches ? 'Toca aquí per anar a qualsevol secció. També pots lliscar des de la vora esquerra de la pantalla.' : 'Prem ⌘K (o Ctrl+K) per anar a qualsevol lloc o obrir una sessió sense tocar el ratolí.') },
1177
- { center: true, mood: 'happy', title: 'Ja està tot a punt.', text: 'Si mai vols repassar-ho, tens el tutorial al menú.', next: 'Finalitza' }
1353
+ { route: 'ordinadors', target: '#help', m: '#help', title: 'Si alguna cosa no va', text: 'A «Com es fa» tens cada pas explicat, què cal al servidor i què mirar si no connecta.' },
1354
+ { target: '[data-act=palette]', m: '#tabbar [data-act=more]', title: () => (mq.matches ? 'Més coses' : 'Un últim truc'), text: () => (mq.matches ? 'A «Més» tens els models, el xat local, la pissarra, l’ajuda i aquest tutorial.' : 'Prem ⌘K (o Ctrl+K) per anar a qualsevol lloc o obrir una sessió sense tocar el ratolí.') },
1355
+ { center: true, mood: 'happy', title: 'Ja està tot a punt.', text: 'Si mai vols repassar-ho, tens el tutorial al menú i l’ajuda a Ordinadors.', next: 'Finalitza' }
1178
1356
  ];
1179
1357
  const tval = (v) => (typeof v === 'function' ? v() : v);
1180
1358
  function tourStart() { T.on = true; T.seen = true; tourGo(0); }