nio-cat 0.9.1 → 0.9.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nio-cat",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "nio: fes servir Claude Code, Codex, Grok, Antigravity i Ollama del teu ordinador des del mòbil.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "f.cat",
@@ -15,6 +15,32 @@ function pickText(ev) {
15
15
  return null;
16
16
  }
17
17
 
18
+ // Grok Build parla el protocol ACP: {method:'session/update', params:{update:{sessionUpdate, content:{text}}}}.
19
+ function handleAcp(ctx, ev, state) {
20
+ const u = ev.params?.update || ev.update;
21
+ if (u && typeof u.sessionUpdate === 'string') {
22
+ const kind = u.sessionUpdate;
23
+ if (kind === 'agent_message_chunk') {
24
+ const t = typeof u.content === 'string' ? u.content : u.content?.text;
25
+ if (t) { state.sawText = true; ctx.stream(t); }
26
+ } else if (kind === 'tool_call') {
27
+ const id = String(u.toolCallId || `tool-${state.n++}`);
28
+ state.tools = state.tools || new Set(); state.tools.add(id);
29
+ ctx.tool({ id, name: u.title || u.kind || 'tool', input: u.rawInput || u.locations || {} });
30
+ } else if (kind === 'tool_call_update' && /completed|failed/i.test(u.status || '')) {
31
+ const out = Array.isArray(u.content) ? u.content.map((c) => c?.content?.text || c?.text || '').join('\n') : '';
32
+ ctx.toolResult({ id: String(u.toolCallId), output: String(u.rawOutput ?? out ?? ''), error: /failed/i.test(u.status) });
33
+ }
34
+ return true;
35
+ }
36
+ if (ev.result && typeof ev.result === 'object' && ev.result.stopReason) {
37
+ ctx.result({ cost: undefined, tokens: ev.result.usage });
38
+ return true;
39
+ }
40
+ if (ev.error && typeof ev.error === 'object' && ev.id !== undefined) { ctx.error(ev.error.message || 'Error'); return true; }
41
+ return false;
42
+ }
43
+
18
44
  function handleLine(ctx, line, state) {
19
45
  const ev = tryJson(line);
20
46
  if (!ev) {
@@ -22,10 +48,12 @@ function handleLine(ctx, line, state) {
22
48
  ctx.stream(line + '\n');
23
49
  return;
24
50
  }
51
+ if (ev.jsonrpc || ev.method || ev.params || ev.update) { if (handleAcp(ctx, ev, state)) return; }
25
52
  const type = String(ev.type || ev.event || ev.kind || '');
26
53
  const sid = ev.session_id || ev.sessionId || ev.session?.id;
27
54
  if (sid) ctx.setNative(sid);
28
55
 
56
+ if (ev.finished_reason === 'error' && !ev.error) { ctx.error('L’agent ha acabat amb error'); return; }
29
57
  if (/error/i.test(type) || ev.status === 'ERROR') {
30
58
  ctx.error(ev.error?.message || ev.error || ev.message || 'Error');
31
59
  return;
package/src/commands.js CHANGED
@@ -54,8 +54,18 @@ function claudeCommands(cwd) {
54
54
  return [...out.values()].map((c) => ({ ...c, hint: c.hint.slice(0, 110) })).sort((a, b) => a.name.localeCompare(b.name)).slice(0, 150);
55
55
  }
56
56
 
57
+ const STATUS = { name: 'status', hint: 'Model, carpeta i permisos de la sessió', kind: 'comanda' };
58
+ // Antigravity resol en mode -p les comandes de només lectura i les skills (sense gastar quota).
59
+ const AGY_BUILTIN = [
60
+ { name: 'usage', hint: 'Ús i límits del compte' }, { name: 'quota', hint: 'Quota restant' }, { name: 'credits', hint: 'Crèdits del compte' },
61
+ { name: 'model', hint: 'Model actual' }, { name: 'effort', hint: 'Nivell de raonament' }, { name: 'skills', hint: 'Skills disponibles' },
62
+ { name: 'permissions', hint: 'Regles de permisos' }, { name: 'hooks', hint: 'Hooks configurats' }, { name: 'config', hint: 'Configuració' },
63
+ { name: 'changelog', hint: 'Novetats de la versió' }, { name: 'help', hint: 'Ajuda' },
64
+ ];
65
+
57
66
  function agentCommands(agent, cwd) {
58
- if (agent === 'codex') return [{ name: 'status', hint: 'Compte, model i carpeta de la sessió', kind: 'comanda' }];
67
+ if (agent === 'codex' || agent === 'grok') return [STATUS];
68
+ if (agent === 'antigravity') return [STATUS, ...AGY_BUILTIN.map((c) => ({ ...c, kind: 'comanda' }))];
59
69
  return agent === 'claude' ? claudeCommands(cwd) : [];
60
70
  }
61
71
 
package/src/hub.js CHANGED
@@ -170,9 +170,10 @@ class Hub extends EventEmitter {
170
170
  // Codex no té /status en mode headless: ho resolem aquí amb el que sí que sap dir.
171
171
  codexStatus(s, bp) {
172
172
  const { execFile } = require('child_process');
173
- execFile(bp, ['login', 'status'], { timeout: 8000 }, (err, out, errOut) => {
173
+ const codex = s.agent === 'codex', name = byId[s.agent]?.name || s.agent;
174
+ execFile(bp, codex ? ['login', 'status'] : ['--version'], { timeout: 8000 }, (err, out, errOut) => {
174
175
  const rows = String(out || errOut || '').split('\n').map((l) => l.trim()).filter(Boolean), login = rows.find((l) => /logged|log in|login/i.test(l) && !/^warning/i.test(l)) || rows[0] || (err ? 'No he pogut llegir el compte' : '');
175
- const lines = [`**Compte:** ${login}`, `**Model:** ${s.model || s.modelUsed || 'el de per defecte'}`, `**Carpeta:** \`${s.cwd}\``, `**Permisos:** ${s.mode}`, `**Conversa:** ${s.nativeId ? 'continua la de Codex' : 'encara no ha començat'}`];
176
+ const lines = [codex ? `**Compte:** ${login}` : `**Versió:** ${login}`, `**Model:** ${s.model || s.modelUsed || 'el de per defecte'}`, `**Carpeta:** \`${s.cwd}\``, `**Permisos:** ${s.mode}`, `**Conversa:** ${s.nativeId ? `continua la de ${name}` : 'encara no ha començat'}`];
176
177
  this.push(s, 'text', { text: lines.join('\n\n') });
177
178
  this.touch(s);
178
179
  });
@@ -231,7 +232,7 @@ class Hub extends EventEmitter {
231
232
 
232
233
  if (!s.title) s.title = text.replace(/\s+/g, ' ').slice(0, 60);
233
234
  this.push(s, 'user', { text });
234
- if (s.agent === 'codex' && /^\/status\s*$/i.test(text)) return this.codexStatus(s, bp);
235
+ if (['codex', 'grok', 'antigravity'].includes(s.agent) && /^\/status\s*$/i.test(text)) return this.codexStatus(s, bp);
235
236
  s.state = 'running';
236
237
  this.push(s, 'status', { state: 'running' });
237
238
 
package/src/jobs.js CHANGED
@@ -115,7 +115,7 @@ class Jobs {
115
115
  cwd = cwd || r.cwd;
116
116
  rule = r.rule;
117
117
  }
118
- if (!['claude', 'codex', 'demo', 'grok', 'antigravity', 'ollama'].includes(agent)) throw httpErr(400, 'Tria Claude Code, Codex o «automàtic»');
118
+ if (!['claude', 'codex', 'demo', 'grok', 'antigravity', 'ollama'].includes(agent)) throw httpErr(400, 'Tria un agent o «automàtic»');
119
119
  if (!cwd) cwd = this.cfg.roots?.[0];
120
120
  if (!cwd || !fs.existsSync(cwd)) throw httpErr(400, 'Aquesta carpeta no existeix');
121
121
  if (b.after && !this.get(b.after)) throw httpErr(400, 'La feina anterior no existeix');
package/src/remote.js CHANGED
@@ -161,7 +161,7 @@ class Remote {
161
161
  const buf = await this.grab(w, q);
162
162
  const info = await this.info();
163
163
  // Un JPEG de pantalla tota negra pesa molt poc: l'ordinador és bloquejat/adormit o falta el permís.
164
- this.frame = { buf, t: Date.now(), w, q, blank: buf.length < 5000, sw: info.w, sh: info.h };
164
+ this.frame = { buf, t: Date.now(), w, q, hash: crypto.createHash('md5').update(buf).digest('hex').slice(0, 12), blank: buf.length < 5000, sw: info.w, sh: info.h };
165
165
  return this.frame;
166
166
  } finally {
167
167
  this.busy = null;
@@ -265,14 +265,20 @@ class Remote {
265
265
  path: '/api/desktop/frame',
266
266
  handler: async ({ res, url }) => {
267
267
  const f = await this.getFrame(Number(url.searchParams.get('w')), Number(url.searchParams.get('q')));
268
+ const same = url.searchParams.get('h') === f.hash;
269
+ if (same) {
270
+ res.writeHead(204, { 'cache-control': 'no-store', 'x-same': '1', 'x-hash': f.hash, 'x-screen-w': String(f.sw || 0), 'x-screen-h': String(f.sh || 0), 'x-blank': f.blank ? '1' : '0', 'access-control-expose-headers': 'x-same, x-hash, x-screen-w, x-screen-h, x-blank' });
271
+ return res.end();
272
+ }
268
273
  res.writeHead(200, {
269
274
  'content-type': 'image/jpeg',
275
+ 'x-hash': f.hash,
270
276
  'content-length': f.buf.length,
271
277
  'cache-control': 'no-store',
272
278
  'x-screen-w': String(f.sw || 0),
273
279
  'x-screen-h': String(f.sh || 0),
274
280
  'x-blank': f.blank ? '1' : '0',
275
- 'access-control-expose-headers': 'x-screen-w, x-screen-h, x-blank',
281
+ 'access-control-expose-headers': 'x-hash, x-screen-w, x-screen-h, x-blank',
276
282
  });
277
283
  res.end(f.buf);
278
284
  },
package/src/telegram.js CHANGED
@@ -284,7 +284,7 @@ class Telegram {
284
284
  this.stop();
285
285
  this.offset = undefined;
286
286
  }
287
- if (['claude', 'codex', 'demo'].includes(body.agent)) t.agent = body.agent;
287
+ if (['claude', 'codex', 'grok', 'antigravity', 'demo'].includes(body.agent)) t.agent = body.agent;
288
288
  if (['llegir', 'preguntar', 'tot'].includes(body.mode)) t.mode = body.mode;
289
289
  if (typeof body.cwd === 'string' && body.cwd) t.cwd = body.cwd;
290
290
  if (typeof body.notify === 'boolean') t.notify = body.notify;
package/web/app/app.js CHANGED
@@ -479,9 +479,10 @@ function renderChrome() {
479
479
  const m = machine(), sc = $('#sideConn');
480
480
  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)}`;
481
481
  sc.setAttribute('role', 'button'); sc.tabIndex = 0; sc.classList.add('switch'); sc.dataset.act = 'machines'; sc.title = 'Ordinadors';
482
- const more = ['mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].includes(r.name);
483
- $('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['approvals', ICON.approvals, 'Aprovacions', active === 'approvals'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
482
+ const more = ['feines', 'memoria', 'approvals', 'mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].includes(r.name);
483
+ $('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
484
484
  .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('')
485
+ .replace('</a><a href="#/ordinadors"', `</a><button type="button" data-act="pantalla-tab">${svg('<path d="M5 3l14 7-6 2.2L10.8 19z"/>')}<span>Pantalla</span></button><a href="#/ordinadors"`)
485
486
  + `<button type="button" data-act="more" ${more ? 'aria-current="page"' : ''}>${svg(ICON.more)}<span>Més</span></button>`;
486
487
  const s = r.name === 'session' ? state.sessions.get(r.id) : null;
487
488
  $('#here').textContent = s ? s.title || 'Sessió nova' : VIEWS[r.name]?.label || '';
@@ -496,8 +497,8 @@ function render() {
496
497
  if (r.name !== 'sessions') manage = null;
497
498
  renderChrome();
498
499
  document.body.classList.toggle('board-mode', board);
499
- document.body.classList.toggle('chat-mode', r.name === 'session' || (r.name === 'mobil' && !!local.engine));
500
- document.body.classList.toggle('no-tabs', r.name === 'session' || board || (r.name === 'mobil' && !!local.engine));
500
+ document.body.classList.toggle('chat-mode', r.name === 'session' || (r.name === 'mobil' && inLocalChat()));
501
+ document.body.classList.toggle('no-tabs', r.name === 'session' || board || (r.name === 'mobil' && inLocalChat()));
501
502
  if (!board && lastName === 'pissarra') window.NioBoard?.unmount();
502
503
  if (board && lastName === 'pissarra' && $('#boardRoot')) return;
503
504
  const changed = lastName !== r.name || r.name === 'session';
@@ -506,9 +507,9 @@ function render() {
506
507
  else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
507
508
  else {
508
509
  const html = r.name === 'mobil' ? viewLocal() : null;
509
- view.innerHTML = html && local.engine ? html : `<div class="page">${html ? html : r.name === 'ordinadors' ? viewComputers() : r.name === 'sessions' ? viewSessions() : r.name === 'approvals' ? viewApprovals() : r.name === 'activitat' ? viewActivity() : r.name === 'feines' ? viewJobs() : r.name === 'memoria' ? viewMemory() : r.name === 'ajuda' ? viewHelp() : viewSettings()}</div>`;
510
+ view.innerHTML = html && inLocalChat() ? html : `<div class="page">${html ? html : r.name === 'ordinadors' ? viewComputers() : r.name === 'sessions' ? viewSessions() : r.name === 'approvals' ? viewApprovals() : r.name === 'activitat' ? viewActivity() : r.name === 'feines' ? viewJobs() : r.name === 'memoria' ? viewMemory() : r.name === 'ajuda' ? viewHelp() : viewSettings()}</div>`;
510
511
  if (r.name === 'mobil') bindLocal();
511
- if (r.name === 'mobil' && machine() && !local.engine) loadOllama();
512
+ if (r.name === 'mobil' && machine() && !inLocalChat()) loadOllama();
512
513
  if (r.name === 'activitat' && machine()) { activity = null; loadActivity(); }
513
514
  if (r.name === 'memoria' && machine()) loadMemory();
514
515
  if (r.name === 'ordinadors' && machine() && state.connected) loadScreen();
@@ -787,9 +788,28 @@ function renameSheet(i) {
787
788
  };
788
789
  });
789
790
  }
791
+ async function pantallaTab() {
792
+ if (!machine() || !state.connected) return toast('Primer connecta un ordinador.', 4000);
793
+ let caps = state.info?.control;
794
+ try { caps = await api('/api/control'); if (state.info) state.info.control = caps; } catch (e) { return toast(e.message, 4500); }
795
+ const d = caps?.escriptori;
796
+ if (d?.enabled) return remoteOpen();
797
+ const body = !d?.installed
798
+ ? `<p class="small">Per veure i controlar l’escriptori cal instal·lar <span class="mono">agent-desktop</span> a ${esc(machine().name)}:</p><p class="mono small">npm i -g agent-desktop</p>`
799
+ : d.ready === false ? `<p class="small">${esc(d.why || 'L’escriptori encara no està a punt.')}</p>`
800
+ : `<p class="small">Activa «Apps de l’escriptori» a ${esc(machine().name)} per veure la pantalla i controlar-la des del mòbil. Cada acció dels agents continuarà demanant la teva aprovació.</p>`;
801
+ openSheet(`<h2>Pantalla</h2><div class="stack">${body}<div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Ara no</button>${d?.installed && d.ready !== false ? '<button class="btn ink" id="pantOn" type="button">Activa i obre</button>' : ''}</div></div>`, (sh) => {
802
+ const go = sh.querySelector('#pantOn'); if (!go) return;
803
+ go.onclick = async () => {
804
+ go.disabled = true;
805
+ try { const r = await api('/api/control', { method: 'PUT', body: { escriptori: true } }); if (state.info) state.info.control = r; closeSheet(); remoteOpen(); }
806
+ catch (err) { toast(err.message, 6000); go.disabled = false; }
807
+ };
808
+ });
809
+ }
790
810
  function moreSheet() {
791
811
  const item = (k) => `<a class="card row" href="${HASH[k]}" style="gap:12px">${svg(VIEWS[k].icon)}<b>${VIEWS[k].label}</b></a>`;
792
- openSheet(`<h2>Més</h2><div class="list">${['mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].map(item).join('')}</div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
812
+ openSheet(`<h2>Més</h2><div class="list">${['feines', 'memoria', 'mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].map(item).join('')}</div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
793
813
  }
794
814
 
795
815
  /* ---------- Sessions */
@@ -820,6 +840,7 @@ async function manageDelete() {
820
840
  manage = fail ? new Set(ids.filter((id) => state.sessions.has(id))) : null;
821
841
  toast(fail ? `No he pogut esborrar ${fail} sessions` : `${ids.length} sessions esborrades`); render();
822
842
  }
843
+ const plain = (t) => `${t}`.replace(/```[\s\S]*?```/g, ' ').replace(/`([^`]*)`/g, '$1').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/^\s{0,3}(#{1,6}|[-*+]|\d+\.|>)\s+/gm, '').replace(/(\*\*|__|\*|_)(?=\S)([^*_]*?\S)\1/g, '$2').replace(/\*\*/g, '').replace(/\s#{1,6}\s/g, ' ').replace(/\s#{1,6}\s/g, ' ').replace(/\s+/g, ' ').trim();
823
844
  function viewSessions() {
824
845
  const list = [...state.sessions.values()].sort((a, b) => b.updated - a.updated);
825
846
  const many = store.machines.length > 1, mach = machine();
@@ -835,7 +856,7 @@ function viewSessions() {
835
856
  <${manage ? 'div role="checkbox" tabindex="0" aria-checked="' + manage.has(s.id) + '" data-pick="' + esc(s.id) + '"' : 'a href="#/s/' + esc(s.id) + '"'} class="card session-card ${manage ? 'pick-mode' : ''} ${manage?.has(s.id) ? 'picked' : ''}">
836
857
  <div class="row between"><span class="agent-tag nosq" data-agent="${esc(s.agent)}">${agentBot(s.agent, sessionMood(s))}${esc(AGENT_NAMES[s.agent] || s.agent)}</span>${stateChip(s)}</div>
837
858
  <div class="title">${esc(s.title || 'Sessió nova')}</div>
838
- ${s.state === 'error' && s.error ? `<div class="last err">${esc(s.error)}</div>` : s.last ? `<div class="last">${esc(s.last)}</div>` : ''}
859
+ ${s.state === 'error' && s.error ? `<div class="last err">${esc(s.error)}</div>` : s.last ? `<div class="last">${esc(plain(s.last))}</div>` : ''}
839
860
  <div class="meta"><span class="mono">${esc(basename(s.cwd))}</span><span>·</span><span>${esc(MODES[s.mode]?.label || '')}</span><span>·</span><span>${ago(s.updated)}</span>${s.cost ? `<span>·</span><span>${money(s.cost)}</span>` : ''}</div>
840
861
  </${manage ? 'div' : 'a'}>`).join('')}</div>` : `<div class="panel empty">${bot('idle')}<h2>Encara no hi ha cap sessió</h2><p>Tria un agent i una carpeta, i explica-li què ha de fer.</p><button class="btn ink" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button></div>`}`;
841
862
  }
@@ -983,28 +1004,45 @@ async function newJobSheet() {
983
1004
  try { projects = await api('/api/projects'); } catch (e) { return toast(e.message); }
984
1005
  const agents = (state.info?.agents || []).filter((a) => a.available && ['claude', 'codex'].includes(a.id));
985
1006
  const prev = (jobsData?.jobs || []).filter((j) => ['pendent', 'en curs', 'feta'].includes(j.state)).slice(0, 15);
986
- const sel = { agent: 'auto', mode: 'preguntar', when: 'ara' };
1007
+ const sel = { agent: 'auto', mode: 'preguntar', when: 'ara', mins: 0 };
987
1008
  openSheet(`<h2>Nova feina</h2>
988
1009
  <label class="f"><span>Què ha de fer?</span><textarea class="field" id="jobPrompt" rows="3" placeholder="Per exemple: revisa les issues noves i fes-me’n un resum"></textarea></label>
989
1010
  <div class="grid-2"><label class="f"><span>Agent</span><select id="jobAgent"><option value="auto">Automàtic (regles)</option>${agents.map((a) => `<option value="${a.id}">${esc(a.name)}</option>`).join('')}</select></label>
990
1011
  <label class="f"><span>Carpeta</span><select id="jobCwd">${projects.map((p) => `<option value="${esc(p.path)}">${esc(p.name)}</option>`).join('')}</select></label></div>
991
1012
  <div class="stack"><div class="section-title" style="margin:0">Permisos</div><div class="seg" id="jobMode" role="group">${Object.entries(MODES).map(([k, m]) => `<button type="button" data-jmode="${k}" aria-pressed="${k === sel.mode}">${m.label}</button>`).join('')}</div></div>
992
- <div class="grid-2"><label class="f"><span>Quan</span><select id="jobWhen"><option value="ara">Ara</option><option value="mes">Més tard…</option>${prev.length ? '<option value="after">Quan acabi una altra feina…</option>' : ''}<option value="cron">Periòdicament…</option></select></label>
993
- <div id="jobWhenX"></div></div>
1013
+ <div class="stack"><div class="section-title" style="margin:0">Quan es comença</div>
1014
+ <div class="seg" id="jobWhen" role="group" aria-label="Quan es comença"><button type="button" data-w="ara" aria-pressed="true">Ara</button><button type="button" data-w="timer" aria-pressed="false">D’aquí a…</button><button type="button" data-w="mes" aria-pressed="false">A una hora</button>${prev.length ? '<button type="button" data-w="after" aria-pressed="false">Després d’una altra</button>' : ''}<button type="button" data-w="cron" aria-pressed="false">Repetida</button></div>
1015
+ <div id="jobWhenX" class="stack"></div></div>
994
1016
  ${agents.some((a) => a.id === 'codex') ? '<label class="row check"><input type="checkbox" id="jobReview"> <span>Quan acabi, que Codex revisi els canvis</span></label>' : ''}
995
1017
  <div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Cancel·la</button><button class="btn ink" id="jobGo" type="button">Posa-la a la cua</button></div>`, (sh) => {
996
1018
  const x = $('#jobWhenX', sh);
997
- $('#jobWhen', sh).onchange = (e) => {
998
- sel.when = e.target.value;
999
- x.innerHTML = sel.when === 'mes' ? '<label class="f"><span>Dia i hora</span><input type="datetime-local" id="jobAt"></label>'
1000
- : sel.when === 'after' ? `<label class="f"><span>Després de</span><select id="jobAfter">${prev.map((j) => `<option value="${esc(j.id)}">${esc(j.title)} (${esc(j.state)})</option>`).join('')}</select></label>`
1001
- : sel.when === 'cron' ? `<label class="f"><span>Cada quan</span><select id="jobCron">${CRON_PRESETS.slice(1).map(([c, l]) => `<option value="${c}">${l}</option>`).join('')}</select></label><input type="text" id="jobCronX" placeholder="min hora dia mes dia-setmana" hidden autocapitalize="off" spellcheck="false" class="mono">` : '';
1019
+ const TIMES = [[5, '5 min'], [15, '15 min'], [30, '30 min'], [60, '1 h'], [180, '3 h'], [480, '8 h']];
1020
+ const hm = (t) => new Date(t).toLocaleString('ca', { weekday: 'short', hour: '2-digit', minute: '2-digit' });
1021
+ const upd = () => {
1022
+ const go = $('#jobGo', sh);
1023
+ if (sel.when === 'timer') { const out = $('#jobTimerOut', sh); out.innerHTML = sel.mins ? `Començarà a les <b>${hm(Date.now() + sel.mins * 60000)}</b>` : 'Tria quant ha d’esperar'; go.textContent = sel.mins ? `Envia-la d’aquí a ${fmtMins(sel.mins)}` : 'Posa-la a la cua'; }
1024
+ else go.textContent = { ara: 'Posa-la a la cua', mes: 'Programa-la', after: 'Encadena-la', cron: 'Programa-la' }[sel.when];
1025
+ };
1026
+ const fmtMins = (m) => (m % 60 === 0 && m >= 60 ? `${m / 60} h` : m > 60 ? `${Math.floor(m / 60)} h ${m % 60} min` : `${m} min`);
1027
+ const setWhen = (w) => {
1028
+ sel.when = w; $$('#jobWhen [data-w]', sh).forEach((b) => b.setAttribute('aria-pressed', b.dataset.w === w));
1029
+ x.innerHTML = w === 'timer' ? `<div class="row" style="flex-wrap:wrap;gap:8px" id="jobTimes">${TIMES.map(([m, l]) => `<button type="button" class="chip" data-m="${m}" aria-pressed="${m === sel.mins}">${l}</button>`).join('')}</div>
1030
+ <div class="row" style="flex-wrap:nowrap"><input class="field" type="number" id="jobMinsN" min="1" max="9999" inputmode="numeric" placeholder="Altre" style="max-width:110px" aria-label="Quantitat"><select id="jobMinsU" aria-label="Unitat"><option value="1">minuts</option><option value="60">hores</option><option value="1440">dies</option></select></div>
1031
+ <p class="muted small" id="jobTimerOut"></p>`
1032
+ : w === 'mes' ? '<label class="f"><span>Dia i hora</span><input type="datetime-local" id="jobAt"></label>'
1033
+ : w === 'after' ? `<label class="f"><span>Quan acabi</span><select id="jobAfter">${prev.map((j) => `<option value="${esc(j.id)}">${esc(j.title)} (${esc(j.state)})</option>`).join('')}</select></label>`
1034
+ : w === 'cron' ? `<label class="f"><span>Cada quan</span><select id="jobCron">${CRON_PRESETS.slice(1).map(([c, l]) => `<option value="${c}">${l}</option>`).join('')}</select></label><input type="text" id="jobCronX" placeholder="min hora dia mes dia-setmana" hidden autocapitalize="off" spellcheck="false" class="mono">` : '';
1002
1035
  const c = $('#jobCron', sh); if (c) c.onchange = () => ($('#jobCronX', sh).hidden = c.value !== 'custom');
1036
+ upd();
1003
1037
  };
1038
+ $('#jobWhen', sh).addEventListener('click', (e) => { const b = e.target.closest('[data-w]'); if (b) setWhen(b.dataset.w); });
1039
+ x.addEventListener('click', (e) => { const b = e.target.closest('[data-m]'); if (!b) return; sel.mins = Number(b.dataset.m); $$('[data-m]', x).forEach((c) => c.setAttribute('aria-pressed', c === b)); $('#jobMinsN', sh).value = ''; upd(); });
1040
+ x.addEventListener('input', (e) => { if (!e.target.matches('#jobMinsN, #jobMinsU')) return; const n = Number($('#jobMinsN', sh).value); sel.mins = n > 0 ? Math.round(n * Number($('#jobMinsU', sh).value)) : 0; $$('[data-m]', x).forEach((c) => c.setAttribute('aria-pressed', false)); upd(); });
1004
1041
  sh.addEventListener('click', (e) => { const m = e.target.closest('[data-jmode]'); if (m) { sel.mode = m.dataset.jmode; $$('[data-jmode]', sh).forEach((b) => b.setAttribute('aria-pressed', b.dataset.jmode === sel.mode)); } if (e.target.closest('[data-close]')) closeSheet(); });
1005
1042
  $('#jobGo', sh).onclick = async () => {
1006
1043
  const prompt = $('#jobPrompt', sh).value.trim(); if (!prompt) return toast('Explica què ha de fer la feina');
1007
1044
  const body = { prompt, agent: $('#jobAgent', sh).value, cwd: $('#jobCwd', sh).value, mode: sel.mode };
1045
+ if (sel.when === 'timer') { if (!sel.mins) return toast('Tria quant ha d’esperar'); body.at = Date.now() + sel.mins * 60000; }
1008
1046
  if (sel.when === 'mes') { const v = $('#jobAt', sh)?.value; if (!v) return toast('Tria el dia i l’hora'); body.at = new Date(v).getTime(); }
1009
1047
  if (sel.when === 'after') body.after = $('#jobAfter', sh).value;
1010
1048
  if (sel.when === 'cron') body.cron = $('#jobCron', sh).value === 'custom' ? $('#jobCronX', sh).value.trim() : $('#jobCron', sh).value;
@@ -1012,7 +1050,7 @@ async function newJobSheet() {
1012
1050
  try {
1013
1051
  const j = await api('/api/jobs', { method: 'POST', body });
1014
1052
  if ($('#jobReview', sh)?.checked) await api('/api/jobs', { method: 'POST', body: { agent: 'codex', cwd: j.cwd, mode: 'llegir', after: j.id, title: `Revisió: ${j.title}`, prompt: 'Revisa els canvis que s’acaben de fer en aquesta carpeta (git diff i fitxers nous). Explica què està bé, què es pot millorar i si hi ha cap error. Context de la feina anterior:\n\n{{anterior}}' } });
1015
- closeSheet(); toast(body.cron ? 'Feina programada' : 'Feina a la cua'); loadJobs();
1053
+ closeSheet(); toast(body.cron || body.at ? 'Feina programada' : 'Feina a la cua'); loadJobs();
1016
1054
  } catch (e) { toast(e.message, 4200); $('#jobGo', sh).disabled = false; }
1017
1055
  };
1018
1056
  if (!isTouch) setTimeout(() => $('#jobPrompt', sh)?.focus(), 80);
@@ -1282,11 +1320,17 @@ function quickFolders(extra = []) {
1282
1320
  }
1283
1321
  const folderChip = (f) => `<button type="button" class="chip ${f.tag === 'fav' ? 'fav' : ''}" data-fs="${esc(f.path)}" title="${esc(f.path)}">${f.tag === 'fav' ? '★ ' : f.tag === 'last' ? '↺ ' : ''}${esc(f.name)}</button>`;
1284
1322
  const favBtn = (p) => `<button class="chip" type="button" data-fav aria-pressed="${getFavs().includes(p)}">${getFavs().includes(p) ? '★ Preferida' : '☆ Desa com a preferida'}</button>`;
1323
+ function crumbBar(d) {
1324
+ const sep = d.path.includes('\\') && !d.path.startsWith('/') ? '\\' : '/', parts = d.path.split(/[\\/]/).filter(Boolean);
1325
+ const pathAt = (i) => (sep === '/' ? '/' : '') + parts.slice(0, i + 1).join(sep) + (sep === '\\' && i === 0 ? '\\' : '');
1326
+ const from = Math.max(0, parts.length - 3);
1327
+ const segs = parts.slice(from).map((n, k) => { const i = from + k, cur = i === parts.length - 1; return `<button type="button" class="crumb${cur ? ' cur' : ''}" data-fs="${esc(pathAt(i))}"${cur ? ' aria-current="page"' : ''}>${esc(n)}</button>`; });
1328
+ return `<div class="fs-bar"><button class="icon-btn" type="button" data-fs="${esc(d.parent || '')}" ${d.parent ? '' : 'disabled'} aria-label="Carpeta de dalt">${svg(ICON.back)}</button><nav class="crumbs mono small" aria-label="Camí" title="${esc(d.path)}"><button type="button" class="crumb" data-fs="${esc(d.home)}" aria-label="Carpeta d’inici">~</button>${from > 0 ? '<span class="sl">/</span><span class="sl">…</span>' : ''}${segs.map((x) => `<span class="sl">/</span>${x}`).join('')}</nav></div>`;
1329
+ }
1285
1330
  function folderSheet(s) {
1286
1331
  let d = null;
1287
1332
  const draw = () => {
1288
- const crumbs = d ? d.path.split(/[\\/]/).filter(Boolean) : [];
1289
- $('#fsBody').innerHTML = !d ? '<p class="muted small">Carregant…</p>' : `<div class="row">${favBtn(d.path)}${quickFolders().slice(0, 8).map(folderChip).join('')}</div><div class="fs-bar"><button class="icon-btn" type="button" data-fs="${esc(d.parent || '')}" ${d.parent ? '' : 'disabled'} aria-label="Carpeta de dalt">${svg(ICON.back)}</button><button class="chip" type="button" data-fs="${esc(d.home)}">Inici</button><span class="fs-path mono small" title="${esc(d.path)}">${esc(crumbs.slice(-2).join(' / ') || '/')}</span></div>
1333
+ $('#fsBody').innerHTML = !d ? '<p class="muted small">Carregant…</p>' : `<div class="row">${favBtn(d.path)}${quickFolders().filter((f) => f.tag === 'fav').map(folderChip).join('')}</div>${crumbBar(d)}
1290
1334
  <div class="proj-list fs-list">${d.dirs.map((x) => `<button type="button" class="proj" data-fs="${esc(x.path)}"><span class="ic">›</span><span class="grow"><div ${x.hidden ? 'class="muted"' : ''}>${esc(x.name)}</div></span></button>`).join('') || '<p class="muted small">No hi ha subcarpetes. Pots triar aquesta.</p>'}</div>`;
1291
1335
  const ok = $('#fsOk'); if (ok) { ok.disabled = !d || d.path === s.cwd; ok.textContent = d && d.path === s.cwd ? 'Ja hi és' : 'Treballa aquí'; }
1292
1336
  };
@@ -1383,7 +1427,7 @@ function focusMain() {
1383
1427
  if (isTouch || $('#sheet')?.open || $('#palette')?.open) return;
1384
1428
  const r = state.route.name;
1385
1429
  if (r === 'session') $('#msg')?.focus({ preventScroll: true });
1386
- else if (r === 'mobil' && local.engine) $('#lmsg')?.focus({ preventScroll: true });
1430
+ else if (r === 'mobil' && inLocalChat()) $('#lmsg')?.focus({ preventScroll: true });
1387
1431
  }
1388
1432
  $('#sheet').addEventListener('click', (e) => { if (e.target.id === 'sheet') closeSheet(); });
1389
1433
  $('#sheet').addEventListener('close', () => setTimeout(focusMain, 0));
@@ -1463,14 +1507,13 @@ async function newSessionSheet(preset = {}) {
1463
1507
  const VIEWS_ = [
1464
1508
  () => `<p class="wiz-q">Quin agent vols fer servir?</p><div class="picker">${[...agents].sort((x, y) => (y.available ? 1 : 0) - (x.available ? 1 : 0)).map((a) => `<button type="button" class="pick ${a.available ? '' : 'off'}" data-agent-id="${esc(a.id)}" aria-pressed="${a.id === sel.agent}"><div class="row between"><span class="agent-tag" data-agent="${esc(a.id)}">${esc(a.name)}</span>${a.beta ? '<span class="chip beta">beta</span>' : ''}</div><div class="sub">${esc(a.available ? a.version || 'Disponible' : 'No instal·lat')}</div></button>`).join('')}</div>`,
1465
1509
  () => {
1466
- const crumbs = fsd ? fsd.path.split(/[\\/]/).filter(Boolean) : [];
1467
- const qf = quickFolders(projects), favN = getFavs().length, quick = qf.filter((f) => f.tag).concat(qf.filter((f) => !f.tag).slice(0, 6)).slice(0, favN + 8).map(folderChip).join('');
1510
+ const quick = quickFolders().filter((f) => f.tag === 'fav').map(folderChip).join('');
1468
1511
  return `<p class="wiz-q">A quina carpeta ha de treballar?</p>
1469
1512
  <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>
1470
1513
  <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>' : ''}${favBtn(fsd?.path || sel.cwd)}</div>
1471
1514
  <div class="row"><button class="chip" type="button" data-gh>${gh ? 'Torna a les carpetes' : 'Clona de GitHub'}</button>${quick}</div>
1472
1515
  ${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>` : '')
1473
- : !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>
1516
+ : !fsd ? '<p class="muted small">Carregant…</p>' : `${crumbBar(fsd)}
1474
1517
  <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>`}`;
1475
1518
  },
1476
1519
  () => {
@@ -1636,34 +1679,27 @@ async function loadOllama(force) {
1636
1679
  paintModels();
1637
1680
  }
1638
1681
  function ollamaSection() {
1639
- if (!machine()) return `<div class="sec-head"><div><h2 class="sec">Ollama a l’ordinador</h2><p class="muted small">Els models d’Ollama són més grans i capaços, però només funcionen en un ordinador. Connecta’n un per veure’ls i descarregar-ne.</p></div><a class="btn" href="#/ordinadors">Ordinadors</a></div>`;
1640
- return `<div class="sec-head"><div><h2 class="sec">Ollama a l’ordinador</h2><p class="muted small">Models que funcionen a ${esc(machine().name)}, sense internet i sense comptes. Els fas servir des de Sessions.</p></div><button class="btn" data-act="ollama-refresh" type="button">${svg(ICON.refresh)}Actualitza</button></div><div id="modelsBody">${state.ollama ? '' : `<div class="panel empty">${bot('work')}<h2>Consultant Ollama…</h2></div>`}</div>`;
1682
+ if (!machine()) return `<h2 class="sec" style="margin-top:36px">Ollama a l’ordinador</h2><div class="stack-lg" style="max-width:640px"><div class="card"><b>Només en un ordinador</b><div class="muted small" style="margin-top:4px">Els models d’Ollama són més grans i capaços. Connecta’n un per veure’ls i descarregar-ne.</div><a class="btn block" style="margin-top:10px" href="#/ordinadors">Ordinadors</a></div></div>`;
1683
+ return `<div class="row between" style="max-width:640px;margin-top:36px"><h2 class="sec" style="margin:0">Ollama a ${esc(machine().name)}</h2><button class="icon-btn" data-act="ollama-refresh" type="button" aria-label="Actualitza">${svg(ICON.refresh)}</button></div><div id="modelsBody" style="max-width:640px;margin-top:12px">${state.ollama ? '' : `<div class="card"><b>Consultant Ollama…</b></div>`}</div>`;
1641
1684
  }
1642
1685
  function pctOf(j) { return j.total ? Math.min(100, Math.round((j.completed / j.total) * 100)) : 0; }
1686
+ const OLLAMA_START = 'Engega Ollama en aquest ordinador: comprova que està instal·lat (si no, digue-m’ho abans d’instal·lar res) i executa «ollama serve» en segon pla. Quan respongui a http://localhost:11434, confirma-m’ho.';
1643
1687
  function paintModels() {
1644
1688
  const box = $('#modelsBody'); if (!box || !state.ollama) return;
1645
1689
  const o = state.ollama, have = new Set(o.models.map((m) => m.name));
1646
1690
  if (!o.available) {
1647
- box.innerHTML = `<div class="panel empty">${bot('sleep')}<h2>Ollama no està engegat en aquest ordinador</h2><p>Instal·la’l des d’<a href="https://ollama.com" target="_blank" rel="noopener">ollama.com</a> i engega’l amb <span class="mono">ollama serve</span>. Després, torna-ho a comprovar.</p><button class="btn" data-act="ollama-refresh" type="button">${svg(ICON.refresh)}Torna-ho a comprovar</button></div>`;
1691
+ box.innerHTML = `<div class="card"><div class="row between"><b>Ollama no està engegat</b><i class="dot"></i></div><div class="muted small" style="margin-top:4px">Un agent pot engegar-lo per tu amb <span class="mono">ollama serve</span>. Si no el tens, l’instal·les des d’<a href="https://ollama.com" target="_blank" rel="noopener">ollama.com</a>.</div><div class="row" style="margin-top:10px"><button class="btn ink" data-act="ollama-start" type="button">Engega’l amb un agent</button><button class="btn" data-act="ollama-refresh" type="button">Torna-ho a comprovar</button></div></div>`;
1648
1692
  return;
1649
1693
  }
1650
1694
  const pulling = o.pulling || [];
1651
- box.innerHTML = `<div class="grid2">
1652
- <div class="stack">
1653
- <section class="panel"><header><h2>Instal·lats</h2><span class="muted small">${o.models.length}</span></header><div class="body">${o.models.length ? o.models.map((m) => `
1654
- <div class="agent-row"><span class="grow"><div class="row"><b class="mono" style="font-size:14px">${esc(m.name)}</b>${(o.running || []).includes(m.name) ? '<span class="chip waiting">A memòria</span>' : ''}</div><div class="muted small">${[m.params, m.quant, bytes(m.size)].filter(Boolean).map(esc).join(' · ')}</div></span>
1655
- <button class="btn sm" data-act="ollama-chat" data-name="${esc(m.name)}" type="button">Sessió</button><button class="icon-btn" data-act="ollama-del" data-name="${esc(m.name)}" type="button" aria-label="Esborra ${esc(m.name)}">${svg(ICON.trash)}</button></div>`).join('') : '<p class="muted small">Encara no n’hi ha cap. Tria’n un de la llista o escriu-ne el nom.</p>'}</div></section>
1656
- ${pulling.length ? `<section class="panel"><header><h2>Descarregant</h2></header><div class="body stack">${pulling.map((j) => `
1657
- <div class="stack" data-pull="${esc(j.name)}"><div class="row between"><b class="mono" style="font-size:14px">${esc(j.name)}</b><button class="btn sm ghost" data-act="ollama-cancel" data-name="${esc(j.name)}" type="button">Cancel·la</button></div><div class="progress"><i style="width:${pctOf(j)}%"></i></div><div class="muted small mono" data-st>${esc(j.status)}${j.total ? ` · ${bytes(j.completed)} de ${bytes(j.total)}` : ''}</div></div>`).join('')}</div></section>` : ''}
1658
- </div>
1659
- <div class="stack">
1660
- <section class="panel"><header><h2>Descarrega un model</h2></header><div class="body stack">
1661
- <form class="row" id="pullForm" style="flex-wrap:nowrap"><input type="text" id="pullIn" placeholder="Nom del model, p. ex. qwen3:4b" autocapitalize="off" spellcheck="false" aria-label="Nom del model"><button class="btn ink" type="submit">${svg(ICON.download)}Descarrega</button></form>
1662
- <p class="muted small">Tens tots els noms a <a href="https://ollama.com/library" target="_blank" rel="noopener">ollama.com/library</a>. Les mides són aproximades.</p>
1663
- <div class="list">${OLLAMA_PICKS.map((p) => { const busy = pulling.some((j) => j.name === p.name), got = have.has(p.name); return `
1664
- <button class="card" type="button" ${got || busy ? 'disabled' : `data-act="ollama-pull" data-name="${p.name}"`} style="${got ? 'opacity:.6' : ''}"><div class="row between"><b class="mono" style="font-size:14px">${p.name}</b><span class="muted small">${got ? 'Instal·lat' : busy ? 'Descarregant…' : p.size}</span></div><div class="muted small" style="margin-top:4px">${p.note}</div></button>`; }).join('')}</div>
1665
- </div></section>
1666
- </div>
1695
+ box.innerHTML = `<div class="stack-lg">
1696
+ <div class="list">${o.models.length ? o.models.map((m) => `<div class="card model-card"><div class="row between"><b class="mono" style="font-size:14px">${esc(m.name)}</b><span class="row" style="gap:6px">${(o.running || []).includes(m.name) ? '<span class="chip waiting">A memòria</span>' : ''}<button class="btn sm" data-act="ollama-chat" data-name="${esc(m.name)}" type="button">Xat</button><button class="icon-btn" data-act="ollama-del" data-name="${esc(m.name)}" type="button" aria-label="Esborra ${esc(m.name)}">${svg(ICON.trash)}</button></span></div><div class="muted small" style="margin-top:4px">${[m.params, m.quant, bytes(m.size)].filter(Boolean).map(esc).join(' · ')}</div></div>`).join('') : '<p class="muted small">Encara no n’hi ha cap model. Tria’n un de sota.</p>'}</div>
1697
+ ${pulling.map((j) => `<div class="card stack" data-pull="${esc(j.name)}"><div class="row between"><b class="mono" style="font-size:14px">${esc(j.name)}</b><button class="btn sm ghost" data-act="ollama-cancel" data-name="${esc(j.name)}" type="button">Cancel·la</button></div><div class="progress"><i style="width:${pctOf(j)}%"></i></div><div class="muted small mono" data-st>${esc(j.status)}${j.total ? ` · ${bytes(j.completed)} de ${bytes(j.total)}` : ''}</div></div>`).join('')}
1698
+ <details class="card"><summary><b>Descarrega un model</b></summary><div class="stack" style="margin-top:10px">
1699
+ <form class="row" id="pullForm" style="flex-wrap:nowrap"><input type="text" id="pullIn" placeholder="Nom, p. ex. qwen3:4b" autocapitalize="off" spellcheck="false" aria-label="Nom del model"><button class="btn ink" type="submit">${svg(ICON.download)}Descarrega</button></form>
1700
+ <div class="list">${OLLAMA_PICKS.map((p) => { const busy = pulling.some((j) => j.name === p.name), got = have.has(p.name); return `<button class="card" type="button" ${got || busy ? 'disabled' : `data-act="ollama-pull" data-name="${p.name}"`} style="${got ? 'opacity:.6' : ''}"><div class="row between"><b class="mono" style="font-size:14px">${p.name}</b><span class="muted small">${got ? 'Instal·lat' : busy ? 'Descarregant…' : p.size}</span></div><div class="muted small" style="margin-top:4px">${p.note}</div></button>`; }).join('')}</div>
1701
+ <p class="muted small">Tots els noms a <a href="https://ollama.com/library" target="_blank" rel="noopener">ollama.com/library</a>.</p>
1702
+ </div></details>
1667
1703
  </div>`;
1668
1704
  }
1669
1705
  const pullDone = new Set(); // descàrregues que han acabat abans que respongués la petició
@@ -1708,6 +1744,7 @@ window.NioBrains = {
1708
1744
  list() {
1709
1745
  const out = [{ id: 'rules', label: 'Ordres ràpides' }];
1710
1746
  if (machine() && state.connected) (state.ollama?.models || []).forEach((m) => out.push({ id: 'ollama:' + m.name, label: `Ollama · ${m.name}` }));
1747
+ if (machine() && state.connected) (state.info?.agents || []).filter((x) => x.available && !['ollama', 'demo'].includes(x.id)).forEach((x) => out.push({ id: 'agent:' + x.id, label: `${AGENT_NAMES[x.id] || x.id} (agent)` }));
1711
1748
  if (local.engine) out.push({ id: 'local', label: `Xat local · ${LOCAL_MODELS.find((x) => x.id === local.model)?.name || local.model}` });
1712
1749
  return out;
1713
1750
  },
@@ -1716,6 +1753,22 @@ window.NioBrains = {
1716
1753
  if (!machine()) throw new Error('No hi ha cap ordinador connectat');
1717
1754
  return (await api('/api/ollama/chat', { method: 'POST', body: { model: id.slice(7), messages, format: 'json' } })).content;
1718
1755
  }
1756
+ if (id.startsWith('agent:')) {
1757
+ if (!machine()) throw new Error('No hi ha cap ordinador connectat');
1758
+ const cwd = (await api('/api/projects').catch(() => []))[0]?.path;
1759
+ const prompt = messages.map((m) => (m.role === 'system' ? m.content : m.content)).join('\n\n') + '\n\nRespon només amb el JSON demanat, sense usar cap eina.';
1760
+ const s = await api('/api/sessions', { method: 'POST', body: { agent: id.slice(6), cwd, mode: 'llegir', title: 'Pissarra', text: prompt } });
1761
+ try {
1762
+ for (let i = 0; i < 120; i++) {
1763
+ await new Promise((r) => setTimeout(r, 1500));
1764
+ const d = await api(`/api/sessions/${s.id}`);
1765
+ if (d.session.state === 'error') throw new Error(d.session.error || 'L’agent ha fallat');
1766
+ if (d.session.state === 'waiting') { api(`/api/sessions/${s.id}/stop`, { method: 'POST' }).catch(() => {}); throw new Error('L’agent demana permís per a una eina'); }
1767
+ if (d.session.state === 'idle') { const t = d.events.filter((e) => e.kind === 'text').map((e) => e.text).join('\n'); if (t) return t; }
1768
+ }
1769
+ throw new Error('L’agent ha tardat massa');
1770
+ } finally { api(`/api/sessions/${s.id}`, { method: 'DELETE' }).then(() => handle({ type: 'removed', session: s.id })).catch(() => {}); }
1771
+ }
1719
1772
  if (id === 'local') {
1720
1773
  if (!local.engine) throw new Error('Primer obre un model a «Xat local»');
1721
1774
  try {
@@ -1760,11 +1813,11 @@ const LOCAL_MODELS = [
1760
1813
  { id: 'Qwen3-1.7B-q4f16_1-MLC', name: 'Qwen 3 · 1,7B', size: '≈ 1,3 GB', note: 'Va bé amb codi i raonaments curts.' },
1761
1814
  { id: 'Phi-3.5-mini-instruct-q4f16_1-MLC', name: 'Phi 3.5 mini · 3,8B', size: '≈ 2,2 GB', note: 'El més potent. Millor en tauleta o ordinador.' }
1762
1815
  ];
1763
- const local = { engine: null, model: null, loading: false, progress: 0, status: '', messages: [], busy: false };
1816
+ const local = { ollama: null, engine: null, model: null, loading: false, progress: 0, status: '', messages: [], busy: false };
1764
1817
  const stripThink = (t) => t.replace(/<think>[\s\S]*?(<\/think>|$)/g, '').trimStart();
1765
1818
  function viewLocal() {
1766
1819
  const hasGpu = 'gpu' in navigator, chosen = local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id);
1767
- if (!local.engine) {
1820
+ if (!inLocalChat()) {
1768
1821
  return `<div class="top"><div><h1>Xat local</h1><p>Un model dins d’aquest dispositiu, per quan no tens l’ordinador a mà. Un cop descarregat, funciona sense connexió i res no surt del dispositiu.</p></div></div>
1769
1822
  <h2 class="sec" style="margin-top:0">En aquest dispositiu</h2>
1770
1823
  <div class="stack-lg" style="max-width:640px">
@@ -1774,14 +1827,15 @@ function viewLocal() {
1774
1827
  </div>
1775
1828
  ${ollamaSection()}`;
1776
1829
  }
1777
- const info = LOCAL_MODELS.find((m) => m.id === local.model);
1830
+ const info = local.ollama ? { name: local.ollama } : LOCAL_MODELS.find((m) => m.id === local.model);
1778
1831
  return `<section class="chat">
1779
- <div class="chat-head"><span>${bot('idle')}</span><div class="grow"><h1>${esc(info?.name || local.model)}</h1><div class="sub">Model dins d’aquest dispositiu</div></div></div>
1780
- <div class="mode-bar"><span class="chip"><span class="agent-tag" data-agent="browser">${esc(info?.name || local.model)}</span></span><span class="chip">Al dispositiu</span><button class="chip" type="button" data-act="lm-change">Canvia de model</button><button class="chip" type="button" data-act="lm-clear">Comença de nou</button></div>
1832
+ <div class="chat-head"><span>${bot('idle')}</span><div class="grow"><h1>${esc(info?.name || local.model)}</h1><div class="sub">${local.ollama ? `Ollama a ${esc(machine()?.name || 'l’ordinador')}` : 'Model dins d’aquest dispositiu'}</div></div></div>
1833
+ <div class="mode-bar"><span class="chip"><span class="agent-tag" data-agent="browser">${esc(info?.name || local.model)}</span></span><span class="chip">${local.ollama ? 'A l’ordinador' : 'Al dispositiu'}</span><button class="chip" type="button" data-act="lm-change">Canvia de model</button><button class="chip" type="button" data-act="lm-clear">Comença de nou</button></div>
1781
1834
  <div class="feed" id="lfeed">${local.messages.length ? '' : `<div class="empty">${bot('idle')}<h2>Pregunta el que vulguis</h2><p>Va bé per resumir, reescriure textos curts o buscar idees.</p></div>`}</div>
1782
1835
  <div class="composer"><div class="inner"><textarea rows="1" id="lmsg" placeholder="Escriu al model…" enterkeyhint="send" aria-label="Missatge per al model"></textarea><button class="send" id="lsend" type="button" aria-label="Envia">${svg(ICON.send)}</button></div></div>
1783
1836
  </section>`;
1784
1837
  }
1838
+ const inLocalChat = () => !!(local.engine || local.ollama);
1785
1839
  function bindLocal() {
1786
1840
  const feed = $('#lfeed'); if (!feed) return;
1787
1841
  for (const m of local.messages) feed.appendChild(m.role === 'user' ? el(`<div class="msg-user">${esc(m.content)}</div>`) : el(`<div class="msg-ai">${md(stripThink(m.content))}</div>`));
@@ -1816,6 +1870,7 @@ async function recoverLocal(text) {
1816
1870
  await loadLocal(id);
1817
1871
  }
1818
1872
  async function loadLocal(id) {
1873
+ local.ollama = null;
1819
1874
  local.loading = true; local.progress = 0; local.status = 'Preparant…'; render();
1820
1875
  try {
1821
1876
  const webllm = await import('https://esm.run/@mlc-ai/web-llm@0.2.86');
@@ -1838,6 +1893,11 @@ async function sendLocal() {
1838
1893
  const out = el('<div class="msg-ai cursor"></div>'); feed.appendChild(out); toBottom(feed);
1839
1894
  local.busy = true; local.stopped = false; setLocalBusy(true); let acc = '';
1840
1895
  try {
1896
+ if (local.ollama) {
1897
+ const r = await api('/api/ollama/chat', { method: 'POST', body: { model: local.ollama, messages: [{ role: 'system', content: LOCAL_SYSTEM }, ...local.messages.slice(-12)] } });
1898
+ if (!local.stopped) acc = r.content || '';
1899
+ out.innerHTML = md(stripThink(acc)); toBottom(feed);
1900
+ } else {
1841
1901
  const chunks = await local.engine.chat.completions.create({
1842
1902
  messages: [{ role: 'system', content: LOCAL_SYSTEM }, ...local.messages.slice(-8)], stream: true,
1843
1903
  temperature: 0.7, top_p: 0.8, frequency_penalty: 0.6, presence_penalty: 0.4, repetition_penalty: 1.15, max_tokens: 700,
@@ -1847,12 +1907,13 @@ async function sendLocal() {
1847
1907
  acc += c.choices[0]?.delta?.content || ''; out.innerHTML = md(stripThink(acc)); if (nearBottom(feed)) toBottom(feed);
1848
1908
  if (local.stopped) break;
1849
1909
  }
1910
+ }
1850
1911
  } catch (e) {
1851
1912
  if (gpuLost(e) && !acc) { local.messages.pop(); out.remove(); feed.lastElementChild?.remove(); local.busy = false; local.stopped = false; setLocalBusy(false); return recoverLocal(text); }
1852
1913
  if (!local.stopped) acc = acc || `Error: ${e.message}`; out.innerHTML = md(stripThink(acc));
1853
1914
  }
1854
1915
  out.classList.remove('cursor');
1855
- if (local.stopped) { try { await local.engine.interruptGenerate(); } catch { /* res */ } }
1916
+ if (local.stopped && local.engine) { try { await local.engine.interruptGenerate(); } catch { /* res */ } }
1856
1917
  if (gpuLost(acc.replace(/^Error: /, ''))) { local.messages.push({ role: 'assistant', content: 'La GPU del mòbil s’ha reiniciat enmig de la resposta.' }); local.busy = false; local.stopped = false; setLocalBusy(false); return recoverLocal(''); }
1857
1918
  local.messages.push({ role: 'assistant', content: stripThink(acc) || '…' }); local.busy = false; local.stopped = false; setLocalBusy(false);
1858
1919
  }
@@ -1863,9 +1924,7 @@ const controlOn = () => !!(state.info?.control?.navegador?.enabled || state.info
1863
1924
  // Aturada d'emergència: sempre a la vista mentre el control estigui actiu.
1864
1925
  function renderPanic() {
1865
1926
  syncScreenBtn();
1866
- let b = $('#panicBtn');
1867
- if (!controlOn() || !machine()) { b?.remove(); return; }
1868
- if (!b) { b = el(`<button class="panic" id="panicBtn" type="button" data-act="panic" aria-label="Aturada d’emergència">${svg(ICON.panic)}<span>Atura-ho tot</span></button>`); document.body.appendChild(b); }
1927
+ $('#panicBtn')?.remove();
1869
1928
  }
1870
1929
  async function panicNow() {
1871
1930
  if (!confirm('S’aturaran tots els agents d’aquest ordinador, es rebutjaran les peticions pendents i es tancarà el navegador.')) return;
@@ -1910,7 +1969,7 @@ async function loadScreen() {
1910
1969
  function syncScreenBtn() { const b = $('[data-act=screen-now]'); if (b) b.disabled = !controlOn(); }
1911
1970
  function onScreen(msg) { if (screen && !screen.error) { screen.last = msg.shot; { const box = $('#screenBody'); if (box) box.innerHTML = screenHtml(); hydrateShots(box); } } }
1912
1971
  /* ---------------------------------------------------------------- pantalla remota (veure i controlar l'ordinador) */
1913
- const RD = { on: false, ctl: false, mods: new Set(), drag: false, q: 55, blank: 0, err: 0, objUrl: null, chain: Promise.resolve(), sw: 0, tap: null, scroll: null, errAt: 0 };
1972
+ const RD = { on: false, ctl: false, mods: new Set(), drag: false, q: 55, blank: 0, err: 0, hash: '', still: 0, scale: 1, objUrl: null, chain: Promise.resolve(), sw: 0, tap: null, scroll: null, errAt: 0 };
1914
1973
  const RD_KEYS = [['Escape', 'Esc'], ['Tab', 'Tab'], ['ArrowUp', '↑'], ['ArrowDown', '↓'], ['ArrowLeft', '←'], ['ArrowRight', '→'], ['Enter', '⏎'], ['Backspace', '⌫']];
1915
1974
  const RD_MODS = [['meta', '⌘'], ['alt', '⌥'], ['ctrl', '⌃'], ['shift', '⇧']];
1916
1975
  const RD_SPECIAL = /^(Escape|Tab|Arrow(Up|Down|Left|Right)|Home|End|PageUp|PageDown|Delete|F([1-9]|1[0-2]))$/;
@@ -1918,7 +1977,8 @@ const RD_SPECIAL = /^(Escape|Tab|Arrow(Up|Down|Left|Right)|Home|End|PageUp|PageD
1918
1977
  async function remoteOpen() {
1919
1978
  const m = machine(); if (!m || RD.on) return;
1920
1979
  if (!(state.info?.control?.escriptori?.enabled || screen?.caps?.escriptori?.enabled)) return toast('Activa primer «Apps de l’escriptori» en aquest ordinador.', 5000);
1921
- Object.assign(RD, { two: 'scroll', z: 1, px: 0, py: 0, on: true, ctl: false, mods: new Set(), drag: false, blank: 0, err: 0, tap: null, scroll: null });
1980
+ const slow = navigator.connection && (navigator.connection.saveData || /^(slow-2g|2g|3g)$/.test(navigator.connection.effectiveType || '') || navigator.connection.type === 'cellular');
1981
+ Object.assign(RD, { hash: '', still: 0, scale: slow ? .6 : 1, q: slow ? 45 : 55, two: 'scroll', z: 1, px: 0, py: 0, on: true, ctl: false, mods: new Set(), drag: false, blank: 0, err: 0, tap: null, scroll: null });
1922
1982
  const root = document.createElement('div');
1923
1983
  root.className = 'rd'; root.id = 'rd'; root.setAttribute('role', 'dialog'); root.setAttribute('aria-label', 'Pantalla remota');
1924
1984
  root.innerHTML = `<div class="rd-bar"><button class="rd-btn" data-rd="close" type="button">Tanca</button><b class="rd-title">${esc(m.name || 'Ordinador')}</b><span class="rd-stat" id="rdStat"></span><span class="grow"></span><button class="rd-btn" data-rd="full" type="button" id="rdFull" hidden>Pantalla completa</button><div class="rd-seg" role="group" aria-label="Mode"><button data-rd="view" type="button" aria-pressed="true">Mirar</button><button data-rd="ctl" type="button" aria-pressed="false">Controlar</button></div></div>
@@ -1960,11 +2020,21 @@ async function rdLoop() {
1960
2020
  const started = performance.now();
1961
2021
  try {
1962
2022
  const stage = $('#rdStage'); if (!stage) break;
1963
- const w = Math.min(2400, Math.max(640, Math.round(stage.clientWidth * Math.min(devicePixelRatio || 1, 2) * Math.min(RD.z, 2))));
1964
- const res = await fetch(`${m.url}/api/desktop/frame?w=${w}&q=${RD.q}&_=${Date.now()}`, { headers: { Authorization: `Bearer ${m.token}` }, cache: 'no-store' });
2023
+ const w = Math.min(2400, Math.max(480, Math.round(stage.clientWidth * Math.min(devicePixelRatio || 1, 2) * Math.min(RD.z, 2) * RD.scale)));
2024
+ const res = await fetch(`${m.url}/api/desktop/frame?w=${w}&q=${RD.q}&h=${RD.hash}&_=${Date.now()}`, { headers: { Authorization: `Bearer ${m.token}` }, cache: 'no-store' });
1965
2025
  if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.error || `Error ${res.status}`); }
2026
+ const t1 = performance.now();
2027
+ if (res.status === 204) { RD.still++; RD.err = 0; n++; await sleep(RD.still > 3 ? 320 : 120); continue; }
1966
2028
  const blob = await res.blob();
1967
2029
  if (!RD.on) break;
2030
+ RD.still = 0; RD.hash = res.headers.get('x-hash') || '';
2031
+ const dl = performance.now() - t1;
2032
+ // Si baixar el fotograma triga massa (dades, una altra xarxa), l'abaixa de mida i qualitat; si va sobrat, el torna a pujar.
2033
+ if (blob.size > 12000) {
2034
+ if (dl > 450) { if (RD.scale > .4) RD.scale = Math.max(.4, RD.scale * .8); else RD.q = Math.max(32, RD.q - 6); }
2035
+ else if (dl < 140 && started - (RD.adj || 0) > 1200) { if (RD.q < 55) RD.q = Math.min(55, RD.q + 4); else RD.scale = Math.min(1, RD.scale * 1.12); RD.adj = started; }
2036
+ if (dl > 450) RD.adj = started;
2037
+ }
1968
2038
  const img = $('#rdImg'), old = RD.objUrl, url = URL.createObjectURL(blob);
1969
2039
  RD.objUrl = url; img.onload = () => { if (old) URL.revokeObjectURL(old); }; img.src = url;
1970
2040
  RD.sw = Number(res.headers.get('x-screen-w')) || RD.sw;
@@ -1977,7 +2047,7 @@ async function rdLoop() {
1977
2047
  RD.err++; rdMsg(RD.err > 1 ? `${e.message || 'Sense connexió'}${/^Error|Activa|captura/.test(e.message) ? '' : ' · reconnectant…'}` : '');
1978
2048
  await sleep(Math.min(3000, 500 * RD.err));
1979
2049
  }
1980
- await sleep(Math.max(30, 110 - (performance.now() - started)));
2050
+ await sleep(Math.max(0, 70 - (performance.now() - started)));
1981
2051
  }
1982
2052
  }
1983
2053
  function rdSend(ev) {
@@ -2021,6 +2091,20 @@ function rdZoom(z, cx, cy) {
2021
2091
  const r = st.getBoundingClientRect(), ox = (cx ?? r.left + r.width / 2) - (r.left + r.width / 2), oy = (cy ?? r.top + r.height / 2) - (r.top + r.height / 2), k = z / RD.z;
2022
2092
  RD.px = ox - (ox - RD.px) * k; RD.py = oy - (oy - RD.py) * k; RD.z = z; rdView();
2023
2093
  }
2094
+ async function screenNow() {
2095
+ const m = machine(); if (!m) return;
2096
+ toast('Fent la captura…');
2097
+ try {
2098
+ const res = await fetch(`${m.url}/api/desktop/frame?w=1600&q=80&_=${Date.now()}`, { headers: { Authorization: `Bearer ${m.token}` }, cache: 'no-store' });
2099
+ if (!res.ok) throw new Error(`Error ${res.status}`);
2100
+ const url = URL.createObjectURL(await res.blob());
2101
+ const d = new Date(), p = (n) => String(n).padStart(2, '0');
2102
+ const name = `nio-pantalla-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}.jpg`;
2103
+ openSheet(`<h2>Captura de la pantalla</h2><img class="screen-img" alt="Captura de la pantalla de l’ordinador" src="${url}" style="width:100%;border-radius:10px;margin:8px 0"><div class="row"><a class="btn ink" href="${url}" download="${name}">Desa la imatge</a><button class="btn" type="button" data-close>Tanca</button></div>`);
2104
+ } catch {
2105
+ try { const s = await api('/api/screen', { method: 'POST' }); onScreen({ shot: s }); toast('Captura feta'); } catch (err) { toast(`No s’ha pogut fer la captura: ${err.message}`, 5000); }
2106
+ }
2107
+ }
2024
2108
  function rdSave() {
2025
2109
  const img = $('#rdImg'); if (!img?.src) return toast('Encara no hi ha cap imatge');
2026
2110
  const d = new Date(), p = (n) => String(n).padStart(2, '0');
@@ -2338,7 +2422,8 @@ document.addEventListener('click', async (e) => {
2338
2422
  case 'ollama-pull': return pullModel(a.dataset.name || $('#pullIn')?.value);
2339
2423
  case 'ollama-cancel': try { await api(`/api/ollama/pull/${encodeURIComponent(a.dataset.name)}`, { method: 'DELETE' }); } catch (err) { toast(err.message); } return;
2340
2424
  case 'ollama-del': return deleteModel(a.dataset.name);
2341
- case 'ollama-chat': try { state.info = await api('/api/info?refresh=1'); } catch { /* res */ } return newSessionSheet({ agent: 'ollama', model: a.dataset.name });
2425
+ case 'ollama-start': return newSessionSheet({ text: OLLAMA_START });
2426
+ case 'ollama-chat': local.ollama = a.dataset.name; local.engine = null; local.model = null; local.messages = []; return render();
2342
2427
  case 'retry': return connect();
2343
2428
  case 'model': { const s = state.sessions.get(state.route.id); if (s) modelSheet(s); return; }
2344
2429
  case 'mode': { const s = state.sessions.get(state.route.id); if (s) modeSheet(s); return; }
@@ -2350,7 +2435,7 @@ document.addEventListener('click', async (e) => {
2350
2435
  case 'job-new': return newJobSheet();
2351
2436
  case 'panic': return panicNow();
2352
2437
  case 'remote-open': remoteOpen(); return;
2353
- case 'screen-now': try { const s = await api('/api/screen', { method: 'POST' }); onScreen({ shot: s }); } catch (err) { toast(err.message, 4500); } return;
2438
+ case 'screen-now': return screenNow();
2354
2439
  case 'mem-new': return memEdit(null);
2355
2440
  case 'mem-all': if (mem) delete mem.hits; return loadMemory();
2356
2441
  case 'activity-refresh': activity = null; return loadActivity();
@@ -2359,13 +2444,14 @@ document.addEventListener('click', async (e) => {
2359
2444
  case 'open-sessions': return go('#/sessions');
2360
2445
  case 'help': return go('#/ajuda');
2361
2446
  case 'more': return moreSheet();
2447
+ case 'pantalla-tab': return pantallaTab();
2362
2448
  case 'folder': { const s = state.sessions.get(state.route.id); return s && folderSheet(s); }
2363
2449
  case 'machines': return machinesSheet();
2364
2450
  case 'mobile-qr': return mobileQrSheet();
2365
2451
  case 'mobile-hint-off': { const m = machine(); LS.set(`nio.mobHint.${m?.id || m?.url}`, true); return render(); }
2366
2452
  case 'git': return gitSheet(state.route.id);
2367
2453
  case 'lm-load': return loadLocal(local.model || LS.get('nio.localModel', LOCAL_MODELS[0].id));
2368
- case 'lm-change': await local.engine?.unload?.(); local.engine = null; local.model = null; return render();
2454
+ case 'lm-change': await local.engine?.unload?.(); local.engine = null; local.model = null; local.ollama = null; local.messages = []; return render();
2369
2455
  case 'lm-clear': local.messages = []; return render();
2370
2456
  case 'palette': return palette();
2371
2457
  case 'tour': return tourStart();
@@ -2438,48 +2524,112 @@ $('#side').addEventListener('click', (e) => { if (e.target.closest('a, button'))
2438
2524
  addEventListener('keydown', (e) => { if (e.key === 'Escape' && document.body.classList.contains('nav-open')) setNav(false); });
2439
2525
  mq.addEventListener('change', () => setNav(false));
2440
2526
  /* ---------------------------------------------------------------- tutorial */
2441
- const TOUR_KEY = 'nio.tour.v2';
2442
- const T = Object.assign({ on: false, i: 0, seen: false }, LS.get(TOUR_KEY, {}));
2527
+ const TOUR_KEY = 'nio.tour.v3';
2528
+ const T = Object.assign({ on: false, i: 0, seen: false, busy: false, pos: null, drawn: '' }, LS.get(TOUR_KEY, {}));
2529
+ T.busy = false; T.drawn = '';
2443
2530
  const saveTour = () => LS.set(TOUR_KEY, { on: T.on, i: T.i, seen: T.seen });
2531
+ const PAGE = '.page .top, .top';
2444
2532
  const TSTEPS = [
2445
- { 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' },
2446
- { 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.' },
2447
- { 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.' },
2448
- { 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.' },
2449
- { 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.' },
2450
- { route: 'mobil', target: '.side nav a[href="#/xat-local"]', m: '.page .model-card', title: 'Models sense internet', text: 'Si no tens l’ordinador a mà, pots fer servir un model petit que funciona dins del mateix mòbil. Si tens Ollama a l’ordinador, aquí hi descarregues models més grans.' },
2451
- { 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.' },
2452
- { route: 'ajuda', target: '#help', m: '#help', title: 'Si alguna cosa no va', text: 'A «Ajuda» tens cada pas explicat, què cal al servidor i què mirar si no connecta.' },
2453
- { target: '[data-act=palette]', m: '#tabbar [data-act=more]', title: () => (mq.matches ? 'Més coses' : 'Un últim truc'), text: () => (mq.matches ? 'A «Més» tens el xat local, la pissarra, els ajustos i l’ajuda, amb aquest tutorial.' : 'Prem ⌘K (o Ctrl+K) per anar a qualsevol lloc o obrir una sessió sense tocar el ratolí.') },
2454
- { center: true, mood: 'happy', title: 'Ja està tot a punt.', text: 'Si mai vols repassar-ho, tens el tutorial a Ajuda.', next: 'Finalitza' }
2533
+ { 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, Grok, Antigravity…) del teu ordinador o d’un servidor. Et faig un recorregut per tot nio, amb un clic a cada apartat. Dura uns dos minuts.', next: 'Comencem' },
2534
+ { route: 'ordinadors', target: '#addComputer', m: '#addComputer', title: '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.' },
2535
+ { route: 'ordinadors', skipIf: () => !machine(), target: '.card.computer.active .c-actions', m: '.card.computer.active .c-actions', title: '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.' },
2536
+ { route: 'ordinadors', skipIf: () => !machine(), target: '#screenSec', m: '#screenSec', title: 'Mira i controla la pantalla', text: 'Aquí actives «Apps de l’escriptori» i el navegador dels agents. Amb l’escriptori actiu pots veure la pantalla de l’ordinador i fer-hi captures des del mòbil.' },
2537
+ { route: 'ordinadors', mobileOnly: true, tapOnly: '#tabbar [data-act=pantalla-tab]', target: '#tabbar [data-act=pantalla-tab]', m: '#tabbar [data-act=pantalla-tab]', title: 'Pantalla, a un toc', text: 'La pestanya «Pantalla» obre l’escriptori de l’ordinador al mòbil, com un escriptori remot, amb zoom. Si encara no està activat, t’ajuda a activar-lo.' },
2538
+ { route: 'sessions', skipIf: () => !machine(), target: '.top [data-act=new]', m: '.top [data-act=new]', title: 'Dona feina a un agent', text: 'Una sessió és una conversa amb un agent dins d’una carpeta. Tries l’agent, la carpeta (les preferides o la que vulguis), els permisos i li expliques què ha de fer.' },
2539
+ { route: 'sessions', skipIf: () => !machine(), target: '.session-card, .panel.empty', m: '.session-card, .panel.empty', title: 'Les teves sessions', text: 'Cada targeta té el robot del color de l’agent i diu què està fent: treballant, esperant-te o acabat. Toca’n una per continuar la conversa.' },
2540
+ { route: 'approvals', skipIf: () => !machine(), target: PAGE, m: PAGE, title: 'Tu decideixes', text: 'En mode «Demana permís», abans d’editar un fitxer o executar una ordre, l’agent t’ho pregunta aquí. Al mòbil hi arribes amb la campana de dalt, i si actives els avisos t’arriba la notificació.' },
2541
+ { route: 'feines', skipIf: () => !machine(), target: PAGE, m: PAGE, title: 'Feines programades', text: 'Programa que un agent faci una cosa per tu: d’aquí a una estona, cada dia o quan toqui. Tries l’agent, la carpeta i el moment, com un temporitzador.' },
2542
+ { route: 'memoria', skipIf: () => !machine(), target: PAGE, m: PAGE, title: 'Memòria compartida', text: 'Notes en text pla que tots els agents poden llegir i escriure: preferències, decisions, context dels projectes. Així no repeteixes res a cada agent.' },
2543
+ { route: 'mobil', target: PAGE, m: PAGE, title: 'Xat local', text: 'Un xat senzill amb un model que funciona dins del mòbil (sense internet) o amb els models d’Ollama de l’ordinador. No toca cap carpeta: només preguntes i respostes.' },
2544
+ { route: 'pissarra', target: '#bdTools', m: '#bdTools', title: 'Pensa en dibuix', text: 'A la pissarra pots fer diagrames a mà alçada o demanar-me’ls escrivint, amb Ollama, amb un agent o amb el model del mòbil.' },
2545
+ { route: 'activitat', skipIf: () => !machine(), target: PAGE, m: PAGE, title: 'Activitat', text: 'El registre de tot el que han fet els agents i de tot el que has aprovat o rebutjat, perquè sempre sàpigues què ha passat.' },
2546
+ { route: 'ajustos', target: PAGE, m: PAGE, title: 'Ajustos', text: 'Aspecte, avisos, Telegram i les opcions de l’ordinador. Tot el que canvia el comportament de nio és aquí.' },
2547
+ { route: 'ajuda', target: '#help', m: '#help', title: 'Si alguna cosa no va', text: 'A «Ajuda» tens cada pas explicat, què cal al servidor i què mirar si no connecta. També hi pots repetir aquest tutorial.' },
2548
+ { route: 'sessions', desktopOnly: true, target: '[data-act=palette]', m: '[data-act=palette]', title: 'Un últim truc', text: 'Prem ⌘K (o Ctrl+K) per anar a qualsevol lloc o obrir una sessió sense tocar el ratolí.' },
2549
+ { center: true, route: 'sessions', mood: 'happy', title: 'Ja està tot a punt.', text: 'Si mai vols repassar-ho, tens el tutorial a Ajuda.', next: 'Finalitza' }
2455
2550
  ];
2456
2551
  const tval = (v) => (typeof v === 'function' ? v() : v);
2457
- function tourStart() { T.on = true; T.seen = true; tourGo(0); }
2458
- function tourEnd(silent) { T.on = false; T.seen = true; saveTour(); $('#tour').hidden = true; if (!silent) toast('Tutorial tancat. El tens al menú quan vulguis.'); }
2459
- function tourGo(i) {
2460
- while (i < TSTEPS.length && TSTEPS[i].skipIf?.()) i++;
2552
+ const tskip = (st) => st.skipIf?.() || (st.mobileOnly && !mq.matches) || (st.desktopOnly && mq.matches);
2553
+ const tpause = (ms) => new Promise((ok) => setTimeout(ok, reduce ? 0 : ms));
2554
+ const twait = (find, ms = 3000) => new Promise((done) => { const t0 = performance.now(); const tick = () => { const e = find(); if (e || performance.now() - t0 > ms) done(e || null); else setTimeout(tick, 70); }; tick(); });
2555
+ const tvisible = (sel) => { const e = sel && document.querySelector(sel); return e && e.getClientRects().length && !e.closest('[inert]') ? e : null; };
2556
+ let tourRun = 0;
2557
+ function tourStart() { T.on = true; T.seen = true; $('#tour').hidden = false; tourGo(0); }
2558
+ function tourEnd(silent) { tourRun++; T.on = false; T.seen = true; T.busy = false; saveTour(); $('#tourTap').classList.remove('on', 'press'); $('#tour').hidden = true; if (!silent) toast('Tutorial tancat. El tens a Ajuda quan vulguis.'); }
2559
+ // El control que tocaria una persona per arribar a l'apartat: menú lateral, pestanya, campana o «Més».
2560
+ function tourVia(route) {
2561
+ const h = HASH[route];
2562
+ if (!mq.matches) return tvisible(`.side nav a[href="${h}"]`);
2563
+ return tvisible(`#tabbar a[href="${h}"]`) || (route === 'approvals' ? tvisible(`.mbar a[href="${h}"]`) : null) || tvisible('#tabbar [data-act=more]');
2564
+ }
2565
+ // Un cercle es mou fins al control i el prem, com un clic de veritat.
2566
+ async function tourTap(e, run) {
2567
+ const dot = $('#tourTap'), r = e.getBoundingClientRect();
2568
+ const x = r.left + Math.min(r.width / 2, 60), y = r.top + r.height / 2;
2569
+ if (!T.pos) { dot.classList.add('nomove'); dot.style.translate = `${innerWidth / 2}px ${innerHeight - 140}px`; void dot.offsetWidth; dot.classList.remove('nomove'); }
2570
+ dot.classList.add('on');
2571
+ dot.style.translate = `${x}px ${y}px`; T.pos = [x, y];
2572
+ await tpause(620); if (run !== tourRun) return false;
2573
+ dot.classList.add('press'); e.classList.add('tour-pressed');
2574
+ await tpause(190); if (run !== tourRun) return false;
2575
+ dot.classList.remove('press'); e.classList.remove('tour-pressed');
2576
+ return true;
2577
+ }
2578
+ async function tourGo(i) {
2579
+ const run = ++tourRun;
2580
+ while (i < TSTEPS.length && tskip(TSTEPS[i])) i++;
2461
2581
  if (i >= TSTEPS.length) return tourEnd(true);
2462
2582
  T.i = Math.max(0, i); saveTour();
2463
- const st = TSTEPS[T.i];
2464
- if (st.route && state.route.name !== st.route) { setTimeout(() => tourDraw(true), 80); go(HASH[st.route]); }
2465
- else tourDraw(true);
2583
+ const st = TSTEPS[T.i], card = $('#tourCard'), spot = $('#tourSpot'), dot = $('#tourTap');
2584
+ $('#tour').hidden = false;
2585
+ T.busy = true; card.classList.add('swap');
2586
+ if (st.tapOnly) {
2587
+ // Només es mostra el toc (la pestanya obriria la pantalla remota).
2588
+ const c = tvisible(st.tapOnly);
2589
+ if (c) { spot.classList.add('fade'); if (!await tourTap(c, run)) return; dot.classList.remove('on'); spot.classList.remove('fade'); }
2590
+ } else if (st.route && state.route.name !== st.route) {
2591
+ const via = tourVia(st.route);
2592
+ spot.classList.add('fade');
2593
+ if (via && !await tourTap(via, run)) return;
2594
+ // Si és dins de «Més», s'obre el menú un instant com faria el dit.
2595
+ if (via?.dataset?.act === 'more') await tpause(380);
2596
+ if (run !== tourRun) return;
2597
+ go(HASH[st.route]);
2598
+ window.scrollTo({ top: 0, behavior: 'instant' });
2599
+ await twait(() => state.route.name === st.route, 1500);
2600
+ dot.classList.remove('on');
2601
+ if (run !== tourRun) return;
2602
+ }
2603
+ const find = () => (st.center ? null : tvisible(tval(mq.matches && 'm' in st ? st.m : st.target)));
2604
+ if (!st.center) await twait(find, 3200);
2605
+ if (run !== tourRun) return;
2606
+ T.busy = false; T.drawn = '';
2607
+ tourDraw(true);
2608
+ spot.classList.remove('fade');
2609
+ card.classList.remove('swap');
2610
+ if (run === tourRun) card.querySelector('[data-tour=next]')?.focus({ preventScroll: true });
2466
2611
  }
2467
2612
  function tourDraw(scroll) {
2468
- if (!T.on) return;
2613
+ if (!T.on || T.busy) return;
2469
2614
  const st = TSTEPS[T.i], card = $('#tourCard'), busy = $('#sheet').open || $('#palette').open;
2470
2615
  $('#tour').hidden = busy; if (busy) return;
2471
- const n = TSTEPS.length - 2;
2472
- card.className = 'tour-card' + (st.center ? ' center' : '');
2473
- card.innerHTML = `<div class="tour-head">${bot(st.mood || 'idle')}${st.center ? '' : `<span class="tour-step">Pas ${T.i} de ${n}</span>`}<button class="tour-x" data-tour="close" type="button" aria-label="Tanca el tutorial">×</button></div>
2616
+ const key = `${T.i}|${mq.matches}`;
2617
+ if (T.drawn !== key) {
2618
+ T.drawn = key;
2619
+ const n = TSTEPS.length - 2, total = TSTEPS.slice(1, -1).filter((x) => !tskip(x)), pos = total.indexOf(st) + 1;
2620
+ card.className = 'tour-card' + (st.center ? ' center' : '');
2621
+ card.innerHTML = `<div class="tour-head">${bot(st.mood || 'idle')}${st.center ? '' : `<span class="tour-step">Pas ${pos} de ${total.length}</span>`}<button class="tour-x" data-tour="close" type="button" aria-label="Tanca el tutorial">×</button></div>
2474
2622
  <h3 id="tourT">${tval(st.title)}</h3><p>${tval(st.text)}</p>
2623
+ ${st.center ? '' : `<div class="tour-dots" aria-hidden="true">${total.map((_, k) => `<i class="${k + 1 === pos ? 'on' : k + 1 < pos ? 'done' : ''}"></i>`).join('')}</div>`}
2475
2624
  <div class="tour-foot">${st.center ? '' : `<div>${T.i > 1 ? '<button class="btn sm ghost" data-tour="back" type="button">Enrere</button>' : ''}</div>`}
2476
2625
  <div class="row">${T.i === 0 ? '<button class="btn sm ghost" data-tour="close" type="button">Ara no</button>' : ''}<button class="btn sm ink" data-tour="next" type="button">${tval(st.next) || 'Següent'}</button></div></div>`;
2477
- window.NioBot?.refresh(); tourPlace(scroll);
2626
+ window.NioBot?.refresh();
2627
+ }
2628
+ tourPlace(scroll);
2478
2629
  }
2479
2630
  function tourTarget() {
2480
- const st = TSTEPS[T.i], sel = st.center ? null : tval(mq.matches && 'm' in st ? st.m : st.target), e = sel && document.querySelector(sel);
2481
- if (!e || !e.getClientRects().length || e.closest('[inert]')) return null;
2482
- return e;
2631
+ const st = TSTEPS[T.i], sel = st.center ? null : tval(mq.matches && 'm' in st ? st.m : st.target);
2632
+ return tvisible(sel);
2483
2633
  }
2484
2634
  function tourPlace(scroll) {
2485
2635
  if (!T.on || $('#tour').hidden) return;
@@ -2518,18 +2668,18 @@ function tourPlace(scroll) {
2518
2668
  if (r.width < 260 && r.right + gap + cw < innerWidth - 12 && (top < 12 || r.bottom + ch > innerHeight)) { left = r.right + gap; top = r.top + r.height / 2 - ch / 2; }
2519
2669
  Object.assign(card.style, { left: Math.min(Math.max(12, left), innerWidth - cw - 12) + 'px', top: Math.min(Math.max(12, top), innerHeight - ch - 12) + 'px', bottom: '' });
2520
2670
  }
2521
- function tourUpdate() { if (T.on) setTimeout(() => tourDraw(false), 30); }
2671
+ function tourUpdate() { if (T.on && !T.busy) setTimeout(() => tourDraw(false), 30); }
2522
2672
  $('#tour').addEventListener('click', (e) => {
2523
2673
  const b = e.target.closest('[data-tour]'); if (!b) return;
2524
2674
  if (b.dataset.tour === 'close') tourEnd(false);
2525
2675
  else if (b.dataset.tour === 'next') tourGo(T.i + 1);
2526
- else if (b.dataset.tour === 'back') { let i = T.i - 1; while (i > 0 && TSTEPS[i].skipIf?.()) i--; tourGo(i); }
2676
+ else if (b.dataset.tour === 'back') { let i = T.i - 1; while (i > 0 && tskip(TSTEPS[i])) i--; tourGo(i); }
2527
2677
  });
2528
- addEventListener('resize', () => tourPlace(false));
2529
- addEventListener('scroll', () => tourPlace(false), { passive: true });
2678
+ addEventListener('resize', () => { if (T.on && !T.busy) tourPlace(false); });
2679
+ addEventListener('scroll', () => { if (T.on && !T.busy) tourPlace(false); }, { passive: true });
2530
2680
  addEventListener('keydown', (e) => {
2531
2681
  if (!T.on || $('#sheet').open || $('#palette').open || e.target.closest?.('input, textarea')) return;
2532
- if (e.key === 'Escape') tourEnd(false); else if (e.key === 'ArrowRight') tourGo(T.i + 1); else if (e.key === 'ArrowLeft' && T.i > 1) tourGo(T.i - 1);
2682
+ if (e.key === 'Escape') tourEnd(false); else if (e.key === 'ArrowRight') tourGo(T.i + 1); else if (e.key === 'ArrowLeft' && T.i > 1) { let i = T.i - 1; while (i > 0 && tskip(TSTEPS[i])) i--; tourGo(i); }
2533
2683
  });
2534
2684
  $('#sheet').addEventListener('close', () => setTimeout(() => tourDraw(false), 30));
2535
2685
  $('#palette').addEventListener('close', () => setTimeout(() => tourDraw(false), 30));
@@ -65,6 +65,7 @@
65
65
  <div class="tour" id="tour" hidden>
66
66
  <div class="tour-spot" id="tourSpot"></div>
67
67
  <div class="tour-card" id="tourCard" role="dialog" aria-modal="false" aria-labelledby="tourT"></div>
68
+ <span class="tour-tap" id="tourTap" aria-hidden="true"></span>
68
69
  </div>
69
70
  <nav class="tabbar" id="tabbar" aria-label="Navegació"></nav>
70
71
  <div class="toasts" id="toasts" popover="manual" aria-live="polite"></div>
package/web/app/style.css CHANGED
@@ -66,7 +66,7 @@ button { cursor: pointer; }
66
66
  kbd { font: 600 11px var(--font); padding: 1px 6px; border: 1px solid var(--border); border-radius: 6px; background: var(--white); color: var(--fg-muted); }
67
67
  .main { min-width: 0; display: flex; flex-direction: column; }
68
68
  .page { padding: 32px 40px 96px; max-width: 1080px; width: 100%; }
69
- .top { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }
69
+ .top { display: flex; flex-direction: column; align-items: flex-start; gap: 14px; margin-bottom: 24px; }
70
70
  .top h1 { font-size: 30px; font-weight: 500; letter-spacing: -.05em; line-height: 1.1; }
71
71
  .top p { margin-top: 4px; color: var(--fg-muted); }
72
72
  .mbar, .scrim { display: none; }
@@ -186,7 +186,7 @@ button.chip:hover { border-color: var(--fg); }
186
186
  /* ---------- Sessions ---------- */
187
187
  .session-card .title { font-size: 16px; font-weight: 500; letter-spacing: -.02em; margin-top: 8px; }
188
188
  .session-card .last.err { color: var(--red); }
189
- .session-card .last { color: var(--fg-muted); font-size: 14px; margin-top: 4px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
189
+ .session-card .last { color: var(--fg-muted); font-size: 14px; margin-top: 4px; line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
190
190
  .session-card .meta { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; font-size: 12px; color: var(--fg-muted); }
191
191
  .fab { position: fixed; right: 24px; bottom: calc(24px + var(--safe-b)); z-index: 15; display: inline-flex; align-items: center; gap: 8px; height: 50px; padding: 0 20px 0 16px; border: 0; border-radius: 16px; background: var(--fg); color: var(--bg); font-weight: 600; box-shadow: 0 14px 30px -14px color-mix(in srgb, var(--fg) 70%, transparent); transition: transform .15s; }
192
192
  .fab:active { transform: scale(.97); }
@@ -304,6 +304,23 @@ dialog.sheet[open] { animation: pop .22s var(--ease); }
304
304
  .tour-card h3 { font-size: 19px; font-weight: 500; letter-spacing: -.035em; line-height: 1.2; }
305
305
  .tour-card.center h3 { font-size: 26px; letter-spacing: -.045em; }
306
306
  .tour-card p { font-size: 14px; color: var(--fg-muted); }
307
+ .tour-card { transition: left .35s var(--ease), top .35s var(--ease), opacity .25s; }
308
+ .tour-card.swap { opacity: .0; pointer-events: none; }
309
+ .tour-spot.fade { box-shadow: 0 0 0 9999px color-mix(in srgb, var(--fg) 42%, transparent); }
310
+ .tour-dots { display: flex; gap: 5px; width: 100%; }
311
+ .tour-card.center .tour-dots { display: none; }
312
+ .tour-dots i { flex: 1; height: 3px; border-radius: 2px; background: color-mix(in srgb, var(--fg) 14%, transparent); transition: background .3s; }
313
+ .tour-dots i.done { background: color-mix(in srgb, var(--fg) 45%, transparent); }
314
+ .tour-dots i.on { background: var(--fg); }
315
+ .tour-tap { position: fixed; left: 0; top: 0; width: 28px; height: 28px; margin: -14px 0 0 -14px; border-radius: 50%; background: rgba(255,255,255,.95); box-shadow: 0 0 0 2px var(--fg), 0 6px 16px rgba(0,0,0,.3); opacity: 0; pointer-events: none; z-index: 2; transition: translate .6s var(--ease), opacity .25s, scale .15s; }
316
+ .tour-tap.on { opacity: 1; }
317
+ .tour-tap.press { scale: .76; }
318
+ .tour-tap.nomove { transition: none; }
319
+ .tour-tap::after { content: ''; position: absolute; inset: -2px; border-radius: 50%; border: 2px solid var(--fg); opacity: 0; }
320
+ .tour-tap.press::after { animation: tourRipple .5s ease-out; }
321
+ @keyframes tourRipple { from { opacity: .7; transform: scale(1); } to { opacity: 0; transform: scale(2.8); } }
322
+ .tour-pressed { scale: .97; transition: scale .15s; }
323
+ @media (prefers-reduced-motion: reduce) { .tour-tap, .tour-card, .tour-spot { transition: none !important; } .tour-tap.press::after { animation: none; } }
307
324
  .tour-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; padding-top: 4px; }
308
325
  .tour-card.center .tour-foot { justify-content: center; }
309
326
 
@@ -395,7 +412,11 @@ body.board-mode .view-enter { animation: none; }
395
412
  .choice .body { min-width: 0; }
396
413
  @media (max-width: 820px) { .choice-grid { grid-template-columns: 1fr; } }
397
414
  .fs-bar { display: flex; align-items: center; gap: 8px; min-width: 0; }
398
- .fs-path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; }
415
+ .crumbs { flex: 1; min-width: 0; display: flex; align-items: center; gap: 2px; overflow: hidden; white-space: nowrap; }
416
+ .crumb { background: none; border: 0; padding: 6px 6px; margin: 0; border-radius: 8px; color: var(--muted, #8a8f98); font: inherit; cursor: pointer; flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
417
+ .crumb:hover { background: color-mix(in srgb, currentColor 10%, transparent); color: inherit; }
418
+ .crumb.cur { color: inherit; font-weight: 600; cursor: default; }
419
+ .crumbs .sl { opacity: .4; flex: none; }
399
420
  .fs-list { max-height: 240px; overflow: auto; }
400
421
 
401
422
  /* Assistent «Nova sessió»: drawer fix al telèfon, capçalera i peu fixos, cos amb scroll */
@@ -650,7 +671,7 @@ dialog.sheet.wiz.compact .sheet-in, .compact .wiz-body { flex: 0 1 auto; }
650
671
  .section-title + .mem-list { margin-bottom: 8px; }
651
672
 
652
673
  /* Capçalera d'una secció dins d'una pàgina */
653
- .sec-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin: 36px 0 14px; }
674
+ .sec-head { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; margin: 36px 0 14px; }
654
675
  .sec-head .sec { margin: 0; }
655
676
  .sec-head p { margin-top: 4px; max-width: 640px; }
656
677
 
@@ -684,3 +705,10 @@ html.rd-open, html.rd-open body { overflow: hidden; }
684
705
  .rd-sep { width: 1px; align-self: stretch; background: #36363a; }
685
706
  .rd-msg { bottom: 84px; }
686
707
  @media (max-width: 560px) { .rd-tools { gap: 4px; padding: 5px; } .rd-tools .rd-btn { padding: 12px 10px; min-width: 44px; font-size: 14px; } .rd-pct { min-width: 52px !important; } }
708
+
709
+ .session-card { padding: 14px 16px; }
710
+ .session-card .meta { margin-top: 8px; }
711
+ #jobWhen { flex-wrap: wrap; }
712
+ #jobWhen button { flex: 1 0 auto; padding: 0 12px; }
713
+ #jobTimes .chip[aria-pressed="true"] { background: var(--fg); color: var(--bg); border-color: var(--fg); }
714
+ #jobMinsU { flex: 1; }
package/web/index.html CHANGED
@@ -198,7 +198,7 @@ main strong { font-weight: 600; }
198
198
  </main>
199
199
 
200
200
  <footer class="footer">
201
- <div class="page">© 2026 nio // un projecte de <a href="https://f.cat">f.cat</a> · <a href="mailto:hola@nio.cat">hola@nio.cat</a> · <a href="/privadesa">Privadesa</a></div>
201
+ <div class="page">© 2026 nio // un projecte de <a href="https://f.cat">f.cat</a> · <a href="mailto:hola@nio.cat">hola@nio.cat</a> · <a href="/novetats">Novetats</a> · <a href="/privadesa">Privadesa</a></div>
202
202
  </footer>
203
203
  <script src="/bot.js" defer></script>
204
204
  <script>
@@ -141,7 +141,7 @@ main strong { font-weight: 600; }
141
141
  </main>
142
142
 
143
143
  <footer class="footer">
144
- <div class="page">© 2026 nio // un projecte de <a href="https://f.cat">f.cat</a> · <a href="mailto:hola@nio.cat">hola@nio.cat</a> · <a href="/privadesa">Privadesa</a></div>
144
+ <div class="page">© 2026 nio // un projecte de <a href="https://f.cat">f.cat</a> · <a href="mailto:hola@nio.cat">hola@nio.cat</a> · <a href="/novetats">Novetats</a> · <a href="/privadesa">Privadesa</a></div>
145
145
  </footer>
146
146
  <script src="/bot.js" defer></script>
147
147
  </body>
package/web/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // nio · service worker: obre l'app encara que no hi hagi connexió i mostra els avisos.
2
- const SHELL = 'nio-shell-v25';
2
+ const SHELL = 'nio-shell-v33';
3
3
  const FILES = ['/app', '/app/style.css', '/app/app.js', '/bot.css', '/bot.js', '/board.css', '/board.js', '/favicon.svg', '/manifest.webmanifest'];
4
4
 
5
5
  self.addEventListener('install', (e) => {