nio-cat 0.9.6 → 0.9.7
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/README.md +1 -0
- package/bin/nio.js +3 -1
- package/package.json +1 -1
- package/src/agents/claude.js +4 -1
- package/src/agents/ollama.js +3 -2
- package/src/hub.js +2 -0
- package/src/notes.js +293 -0
- package/src/server.js +19 -2
- package/src/usage.js +79 -0
- package/web/app/app.js +194 -43
- package/web/app/demo.js +202 -10
- package/web/app/index.html +3 -2
- package/web/app/notes.css +154 -0
- package/web/app/notes.js +504 -0
- package/web/app/style.css +26 -0
- package/web/index.html +7 -2
- package/web/sw.js +2 -2
package/web/app/app.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// nio · app. Parla directament amb el programa nio del teu ordinador (fetch + flux d'esdeveniments).
|
|
2
2
|
// Sense frameworks ni compilació. També inclou un ordinador de prova que viu dins del navegador.
|
|
3
3
|
|
|
4
|
+
import { createNotes } from './notes.js';
|
|
5
|
+
|
|
4
6
|
const $ = (s, e = document) => e.querySelector(s);
|
|
5
7
|
const $$ = (s, e = document) => [...e.querySelectorAll(s)];
|
|
6
8
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
@@ -16,7 +18,7 @@ const CHIP_ICON = {
|
|
|
16
18
|
mode: '<path d="M12 3l7 3v5c0 4.5-3 8-7 10-4-2-7-5.5-7-10V6z"/>',
|
|
17
19
|
folder: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
|
18
20
|
model: '<rect x="6" y="6" width="12" height="12" rx="2.5"/><path d="M9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3"/>',
|
|
19
|
-
|
|
21
|
+
usage: '<path d="M4 16a8 8 0 1 1 16 0"/><path d="M12 16l4-5"/>',
|
|
20
22
|
};
|
|
21
23
|
const svg = (d) => `<svg class="ico" viewBox="0 0 24 24" aria-hidden="true">${d}</svg>`;
|
|
22
24
|
const ICON = {
|
|
@@ -24,6 +26,7 @@ const ICON = {
|
|
|
24
26
|
jobs: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8 9h8M8 13h5M8 17h3"/>',
|
|
25
27
|
screen: '<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M9 20h6M12 16v4"/><circle cx="12" cy="10" r="2"/>',
|
|
26
28
|
panic: '<circle cx="12" cy="12" r="9"/><path d="M8.5 8.5l7 7M15.5 8.5l-7 7"/>',
|
|
29
|
+
notes: '<path d="M6 4h12v16H6z"/><path d="M9 9h6M9 13h6M9 17h3"/>',
|
|
27
30
|
memory: '<path d="M6 4h9l3 3v13H6z"/><path d="M9 11h6M9 15h6M14 4v4h4"/>',
|
|
28
31
|
sessions: '<path d="M4 6h16M4 12h16M4 18h10"/>',
|
|
29
32
|
approvals: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
|
|
@@ -70,6 +73,64 @@ const store = {
|
|
|
70
73
|
};
|
|
71
74
|
const machine = () => store.machines[store.active] || store.machines[0] || null;
|
|
72
75
|
|
|
76
|
+
/* ---------------------------------------------------------------- cua de tasques
|
|
77
|
+
Les tasques es desen al mòbil i s'envien soles quan l'ordinador es connecta. */
|
|
78
|
+
const mkey = (m) => m?.id || m?.url;
|
|
79
|
+
const queue = {
|
|
80
|
+
get all() { return LS.get('nio.queue.v1', []); },
|
|
81
|
+
set all(v) { LS.set('nio.queue.v1', v); },
|
|
82
|
+
mine() { const k = mkey(machine()); return this.all.filter((t) => t.mid === k); },
|
|
83
|
+
add(t) { const m = machine(); this.all = [...this.all, { id: Math.random().toString(36).slice(2, 10), mid: mkey(m), at: Date.now(), ...t }]; },
|
|
84
|
+
drop(id) { this.all = this.all.filter((t) => t.id !== id); }
|
|
85
|
+
};
|
|
86
|
+
let flushing = false;
|
|
87
|
+
async function flushQueue() {
|
|
88
|
+
if (flushing || !state.connected) return;
|
|
89
|
+
const todo = queue.mine(); if (!todo.length) return;
|
|
90
|
+
flushing = true;
|
|
91
|
+
let sent = 0;
|
|
92
|
+
try {
|
|
93
|
+
for (const t of todo) {
|
|
94
|
+
if (!state.connected) break;
|
|
95
|
+
try {
|
|
96
|
+
const { id, mid, at, ...body } = t;
|
|
97
|
+
const s = await api('/api/sessions', { method: 'POST', body });
|
|
98
|
+
state.sessions.set(s.id, s); queue.drop(id); sent++;
|
|
99
|
+
} catch (e) {
|
|
100
|
+
if (e.name === 'TypeError') break; // l'ordinador s'ha apagat un altre cop: es manté a la cua
|
|
101
|
+
queue.drop(t.id); toast(`No s’ha pogut enviar «${t.title || t.text.slice(0, 30)}»: ${e.message}`, 5000); // error de veritat (p. ex. carpeta inexistent)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} finally { flushing = false; }
|
|
105
|
+
if (sent) { toast(sent === 1 ? 'S’ha enviat 1 tasca de la cua' : `S’han enviat ${sent} tasques de la cua`, 4200); render(); }
|
|
106
|
+
}
|
|
107
|
+
function queueSheet() {
|
|
108
|
+
if (!machine()) return go('#/sessions');
|
|
109
|
+
const m = machine(), last = LS.get(`nio.last.${mkey(m)}`, {});
|
|
110
|
+
const agents = Object.entries(AGENT_NAMES).filter(([k]) => !['demo', 'browser', 'ollama'].includes(k));
|
|
111
|
+
const sel = { agent: last.agent && AGENT_NAMES[last.agent] ? last.agent : 'claude', cwd: last.cwd || '', mode: last.mode || 'preguntar' };
|
|
112
|
+
openSheet(`<h2>Tasca per a quan l’ordinador estigui engegat</h2>
|
|
113
|
+
<p class="muted small">Es desa en aquest dispositiu i s’envia sola quan nio es connecti amb ${esc(m.name)}.</p>
|
|
114
|
+
<label class="f"><span>Agent</span><select id="qAgent">${agents.map(([k, n]) => `<option value="${k}" ${k === sel.agent ? 'selected' : ''}>${esc(n)}</option>`).join('')}</select></label>
|
|
115
|
+
<label class="f"><span>Carpeta de l’ordinador</span><input type="text" id="qCwd" class="mono" value="${esc(sel.cwd)}" placeholder="/Users/…/projecte" autocapitalize="off" spellcheck="false"></label>
|
|
116
|
+
<label class="f"><span>Permisos</span><select id="qMode">${Object.entries(MODES).map(([k, x]) => `<option value="${k}" ${k === sel.mode ? 'selected' : ''}>${esc(x.label)}</option>`).join('')}</select></label>
|
|
117
|
+
<label class="f"><span>Què ha de fer?</span><textarea class="field" id="qText" rows="5" placeholder="Per exemple: arregla els tests que fallen"></textarea></label>
|
|
118
|
+
<div class="row" style="justify-content:flex-end"><button class="btn ghost" type="button" data-close>Cancel·la</button><button class="btn ink" type="button" id="qSave">Posa-la a la cua</button></div>`, (sh) => {
|
|
119
|
+
sh.querySelector('[data-close]').onclick = closeSheet;
|
|
120
|
+
$('#qSave', sh).onclick = () => {
|
|
121
|
+
const text = $('#qText', sh).value.trim(), cwd = $('#qCwd', sh).value.trim();
|
|
122
|
+
if (!cwd) return toast('Escriu la carpeta de l’ordinador');
|
|
123
|
+
if (!text) return toast('Explica a l’agent què ha de fer');
|
|
124
|
+
queue.add({ agent: $('#qAgent', sh).value, cwd, mode: $('#qMode', sh).value, text, title: text.slice(0, 60) });
|
|
125
|
+
closeSheet(); toast('A la cua. S’enviarà quan l’ordinador estigui connectat.', 4200); render();
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function queueList() {
|
|
130
|
+
const q = queue.mine(); if (!q.length) return '';
|
|
131
|
+
return `<h2 class="sec">A la cua (${q.length})</h2><div class="list">${q.map((t) => `<div class="card"><div class="row between"><span class="agent-tag" data-agent="${esc(t.agent)}">${esc(AGENT_NAMES[t.agent] || t.agent)}</span><button class="btn ghost sm" type="button" data-qdrop="${esc(t.id)}">Treu-la</button></div><div class="title">${esc(t.title)}</div><div class="meta"><span class="mono">${esc(basename(t.cwd))}</span><span>·</span><span>${state.connected ? 'enviant…' : 'espera l’ordinador'}</span><span>·</span><span>${ago(t.at)}</span></div></div>`).join('')}</div>`;
|
|
132
|
+
}
|
|
133
|
+
|
|
73
134
|
/* ---------------------------------------------------------------- utilitats */
|
|
74
135
|
function toast(msg, ms = 2800) {
|
|
75
136
|
const box = $('#toasts'), t = document.createElement('div');
|
|
@@ -236,7 +297,7 @@ async function connect() {
|
|
|
236
297
|
if (!res.ok || !res.body) throw new Error(`Error ${res.status}`);
|
|
237
298
|
state.connected = true; state.lastError = null; retry = 0;
|
|
238
299
|
rememberIdentity(m.url, state.info.identity);
|
|
239
|
-
renameMachine(state.info.host, state.info.id); syncServiceWorker(); render(); brainsForBoard(); loadVoice().then(() => state.route.name === 'session' && renderComposer?.());
|
|
300
|
+
renameMachine(state.info.host, state.info.id); syncServiceWorker(); render(); brainsForBoard(); loadVoice().then(() => state.route.name === 'session' && renderComposer?.()); flushQueue();
|
|
240
301
|
const reader = res.body.getReader(), dec = new TextDecoder(); let buf = '';
|
|
241
302
|
// Alguns túnels (Cloudflare des d'alguns servidors) no deixen passar el flux en directe: sense «hello» en 5 s, es passa a consultar cada 2 s.
|
|
242
303
|
gotHello = false; let poll = false;
|
|
@@ -309,7 +370,7 @@ function handle(msg) {
|
|
|
309
370
|
case 'hello':
|
|
310
371
|
gotHello = true; state.synced = true;
|
|
311
372
|
state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
|
|
312
|
-
state.approvals = new Map(msg.approvals.map((a) => [a.id, a]));
|
|
373
|
+
state.approvals = new Map(msg.approvals.map((a) => [a.id, a])); state.usage = msg.usage || state.usage; notesCtl?.reload();
|
|
313
374
|
state.loaded.clear(); state.events.clear();
|
|
314
375
|
if (r.name === 'session') openSession(r.id); else render();
|
|
315
376
|
break;
|
|
@@ -325,6 +386,7 @@ function handle(msg) {
|
|
|
325
386
|
break;
|
|
326
387
|
case 'event': {
|
|
327
388
|
const evs = state.events.get(msg.session);
|
|
389
|
+
if (msg.event.kind === 'result') api('/api/usage').then((u) => { state.usage = u; if (state.route.name === 'session') renderSessionHead(); }).catch(() => {});
|
|
328
390
|
if (evs) { const was = evs.get(msg.event.seq); evs.set(msg.event.seq, msg.event); if (r.name === 'session' && r.id === msg.session) { putEvent(msg.event); if (msg.event.kind === 'text' && !msg.event.open && (!was || was.open)) autoSpeak(msg.event); } }
|
|
329
391
|
break;
|
|
330
392
|
}
|
|
@@ -333,6 +395,8 @@ function handle(msg) {
|
|
|
333
395
|
if (e) { e.text += msg.text; if (r.name === 'session' && r.id === msg.session) streamText(msg.session, e); }
|
|
334
396
|
break;
|
|
335
397
|
}
|
|
398
|
+
case 'usage': state.usage = msg.usage; if (r.name === 'session') renderSessionHead(); break;
|
|
399
|
+
case 'note': case 'note-gone': notesCtl?.onEvent(msg); break;
|
|
336
400
|
case 'ollama': onOllama(msg); break;
|
|
337
401
|
case 'job': onJob(msg); break;
|
|
338
402
|
case 'screen': onScreen(msg); break;
|
|
@@ -447,6 +511,7 @@ function checkPairHash() {
|
|
|
447
511
|
const VIEWS = {
|
|
448
512
|
sessions: { label: 'Sessions', icon: ICON.sessions, group: 'Feina' },
|
|
449
513
|
approvals: { label: 'Aprovacions', icon: ICON.approvals, group: 'Feina' },
|
|
514
|
+
notes: { label: 'Notes', icon: ICON.notes, group: 'Feina' },
|
|
450
515
|
feines: { label: 'Feines', icon: ICON.jobs, group: 'Feina' },
|
|
451
516
|
memoria: { label: 'Memòria', icon: ICON.memory, group: 'Ordinadors' },
|
|
452
517
|
ordinadors: { label: 'Ordinadors', icon: ICON.machine, group: 'Ordinadors' },
|
|
@@ -456,15 +521,15 @@ const VIEWS = {
|
|
|
456
521
|
ajustos: { label: 'Ajustos', icon: ICON.settings, group: 'Compte' },
|
|
457
522
|
ajuda: { label: 'Ajuda', icon: ICON.help, group: 'Compte' }
|
|
458
523
|
};
|
|
459
|
-
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', feines: '#/feines', activitat: '#/activitat', memoria: '#/memoria', ordinadors: '#/ordinadors', mobil: '#/xat-local', pissarra: '#/pissarra', ajustos: '#/ajustos', ajuda: '#/ajuda' };
|
|
460
|
-
const NEEDS_MACHINE = new Set(['
|
|
524
|
+
const HASH = { sessions: '#/sessions', approvals: '#/aprovacions', notes: '#/notes', feines: '#/feines', activitat: '#/activitat', memoria: '#/memoria', ordinadors: '#/ordinadors', mobil: '#/xat-local', pissarra: '#/pissarra', ajustos: '#/ajustos', ajuda: '#/ajuda' };
|
|
525
|
+
const NEEDS_MACHINE = new Set(['session']);
|
|
461
526
|
function parseRoute() {
|
|
462
527
|
const h = location.hash.replace(/^#\/?/, '');
|
|
463
528
|
if (h.startsWith('s/')) return { name: 'session', id: h.slice(2) };
|
|
464
529
|
if (h === 'ordinador' || h === 'pantalla') return { name: 'ordinadors' }; // adreces antigues
|
|
465
530
|
if (h === 'models') return { name: 'mobil' };
|
|
466
531
|
if (h === 'mobil') return { name: 'mobil' };
|
|
467
|
-
const k = Object.keys(HASH).find((k) => HASH[k] === '#/' + h);
|
|
532
|
+
const k = Object.keys(HASH).find((k) => HASH[k] === '#/' + h.split('?')[0]);
|
|
468
533
|
return { name: k || 'sessions' };
|
|
469
534
|
}
|
|
470
535
|
function go(hash) { if (location.hash === hash) route(); else location.hash = hash; }
|
|
@@ -474,8 +539,8 @@ function route() {
|
|
|
474
539
|
state.route = parseRoute();
|
|
475
540
|
const canon = state.route.name === 'session' ? null : HASH[state.route.name];
|
|
476
541
|
if (canon && location.hash && location.hash !== canon && /^#\/(pantalla|models|ordinador)$/.test(location.hash)) history.replaceState(null, '', canon);
|
|
477
|
-
// Sense cap ordinador,
|
|
478
|
-
if (!machine() && NEEDS_MACHINE.has(state.route.name)) { history.replaceState(null, '', '#/
|
|
542
|
+
// Sense cap ordinador, cada pàgina es veu igual i explica què hi faltaria; només una sessió concreta no té sentit.
|
|
543
|
+
if (!machine() && NEEDS_MACHINE.has(state.route.name)) { history.replaceState(null, '', '#/sessions'); state.route = { name: 'sessions' }; }
|
|
479
544
|
if (state.route.name === 'session') openSession(state.route.id); else render();
|
|
480
545
|
}
|
|
481
546
|
|
|
@@ -492,7 +557,7 @@ function renderChrome() {
|
|
|
492
557
|
const m = machine(), sc = $('#sideConn');
|
|
493
558
|
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)}`;
|
|
494
559
|
sc.setAttribute('role', 'button'); sc.tabIndex = 0; sc.classList.add('switch'); sc.dataset.act = 'machines'; sc.title = 'Ordinadors';
|
|
495
|
-
const more = ['feines', 'memoria', 'approvals', 'mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].includes(r.name);
|
|
560
|
+
const more = ['notes', 'feines', 'memoria', 'approvals', 'mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].includes(r.name);
|
|
496
561
|
$('#tabbar').innerHTML = [['sessions', ICON.sessions, 'Sessions', active === 'sessions'], ['ordinadors', ICON.machine, 'Ordinadors', active === 'ordinadors']]
|
|
497
562
|
.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('')
|
|
498
563
|
.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"`)
|
|
@@ -506,6 +571,8 @@ function renderChrome() {
|
|
|
506
571
|
document.title = n ? `(${n}) nio` : `nio // ${s ? s.title || 'Sessió' : VIEWS[r.name]?.label || 'Panell'}`;
|
|
507
572
|
if ('setAppBadge' in navigator) (n ? navigator.setAppBadge(n) : navigator.clearAppBadge()).catch?.(() => {});
|
|
508
573
|
}
|
|
574
|
+
let notesCtl = null;
|
|
575
|
+
const getNotes = () => (notesCtl ||= createNotes({ api, nodeAudio, toast, esc, machine, voiceOn, voiceSheet, isDemo, openSheet, closeSheet, mobileQrSheet }));
|
|
509
576
|
function render() {
|
|
510
577
|
const r = state.route, view = $('#view'), board = r.name === 'pissarra';
|
|
511
578
|
if (r.name !== 'sessions') manage = null;
|
|
@@ -513,11 +580,17 @@ function render() {
|
|
|
513
580
|
document.body.classList.toggle('board-mode', board);
|
|
514
581
|
document.body.classList.toggle('chat-mode', r.name === 'session' || (r.name === 'mobil' && inLocalChat()));
|
|
515
582
|
document.body.classList.toggle('no-tabs', r.name === 'session' || board || (r.name === 'mobil' && inLocalChat()));
|
|
583
|
+
if (r.name !== 'notes' && lastName === 'notes') notesCtl?.unmount();
|
|
516
584
|
if (!board && lastName === 'pissarra') window.NioBoard?.unmount();
|
|
517
585
|
if (board && lastName === 'pissarra' && $('#boardRoot')) return;
|
|
586
|
+
if (r.name === 'notes' && lastName === 'notes' && $('#notesRoot') && machine()) return; // l'editor no es toca quan l'app es repinta
|
|
518
587
|
const changed = lastName !== r.name || r.name === 'session';
|
|
519
588
|
lastName = r.name;
|
|
520
589
|
if (r.name === 'session') renderSession();
|
|
590
|
+
else if (r.name === 'notes') {
|
|
591
|
+
view.innerHTML = machine() ? '<div class="page" id="notesRoot"></div>' : `<div class="page"><div class="top"><div><h1>Notes</h1><p>Escriu, grava i deixa que un model ho ordeni.</p></div></div>${noMachine('Les notes es desen a l’ordinador. Connecta’n un per escriure’n i gravar-ne.')}</div>`;
|
|
592
|
+
if (machine()) getNotes().mount($('#notesRoot'));
|
|
593
|
+
}
|
|
521
594
|
else if (board) { view.innerHTML = '<div class="page"><div class="board-root" id="boardRoot"></div></div>'; window.NioBoard?.mount($('#boardRoot')); brainsForBoard(); }
|
|
522
595
|
else {
|
|
523
596
|
const html = r.name === 'mobil' ? viewLocal() : null;
|
|
@@ -539,6 +612,7 @@ function render() {
|
|
|
539
612
|
/* ---------- Ordinadors (llista, afegir i detall de l'actiu) */
|
|
540
613
|
const deviceId = () => { let d = LS.get('nio.device', ''); if (!d) { d = Math.random().toString(36).slice(2, 12); LS.set('nio.device', d); } return d; };
|
|
541
614
|
const isPhone = () => isTouch || matchMedia('(max-width: 820px)').matches;
|
|
615
|
+
const INSTALL_CMD = 'curl -fsSL https://www.nio.cat/install.sh | sh';
|
|
542
616
|
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>`;
|
|
543
617
|
// Estat de la resta d'ordinadors (el connectat ja té el seu flux en directe): una consulta lleugera.
|
|
544
618
|
const reach = new Map();
|
|
@@ -640,24 +714,38 @@ async function disablePush() {
|
|
|
640
714
|
try { const sub = await (await navigator.serviceWorker.ready).pushManager.getSubscription(); await sub?.unsubscribe(); } catch { /* res */ }
|
|
641
715
|
pushOn = false; toast('Avisos desactivats en aquest dispositiu'); render();
|
|
642
716
|
}
|
|
717
|
+
// Sense cap ordinador: el mateix avís a cada pàgina, amb què s'hi podria fer i on connectar-ne un.
|
|
718
|
+
function noMachine(text) {
|
|
719
|
+
return `<div class="panel empty">${bot('sleep')}<h2>No tens cap ordinador connectat</h2><p>${text}</p><div class="row" style="justify-content:center"><button class="btn ink" data-act="add-computer" type="button">${svg(ICON.plus)}Connecta un ordinador</button><button class="btn" data-act="demo" type="button">Prova una demo</button></div></div>`;
|
|
720
|
+
}
|
|
643
721
|
function viewSettings() {
|
|
644
|
-
const head = '<div class="top"><div><h1>Ajustos</h1><p>Avisos al mòbil i
|
|
722
|
+
const head = '<div class="top"><div><h1>Ajustos</h1><p>Avisos al mòbil, veu i Telegram. Es configuren per a cada ordinador.</p></div></div>';
|
|
723
|
+
if (!machine()) return `${head}<div class="stack-lg" style="max-width:640px">${settingsOff()}</div>`;
|
|
645
724
|
const avisos = machine() ? `<section class="panel"><header><h2>Avisos al mòbil</h2></header><div class="body stack"><p class="small">Rep un avís quan un agent et demana permís o acaba la feina.</p>${isIOS && !standalone ? '<p class="muted small">A l’iPhone, primer afegeix nio a la pantalla d’inici (Comparteix → Afegeix a la pantalla d’inici) i obre’l des d’allà.</p>' : ''}${pushOn ? '<p class="small"><span class="chip ok">' + svg(ICON.check) + 'Avisos activats en aquest dispositiu</span></p>' : ''}<div class="row">${pushOn ? '<button class="btn ink grow" data-act="push-test" type="button">Envia’n un de prova</button><button class="btn" data-act="push-off" type="button">Desactiva</button>' : '<button class="btn ink grow" data-act="push" type="button">Activa els avisos</button>'}</div></div></section>` : '';
|
|
646
725
|
if (machine() && pushOn === null) refreshPush();
|
|
647
726
|
if (machine() && tgState === undefined) loadTelegram();
|
|
648
727
|
return `${head}<div class="stack-lg" style="max-width:640px">${avisos}${voicePanel()}<div id="tgPanel">${telegramPanel()}</div></div>`;
|
|
649
728
|
}
|
|
729
|
+
// Ajustos sense ordinador: un sol avís a dalt i les seccions de mostra, desactivades.
|
|
730
|
+
function settingsOff() {
|
|
731
|
+
const sec = (t, d, b) => `<section class="panel off" aria-disabled="true"><header><h2>${t}</h2></header><div class="body stack"><p class="small">${d}</p><div class="row"><button class="btn" type="button" disabled>${b}</button></div></div></section>`;
|
|
732
|
+
return `<div class="card off-note"><div class="grow"><b>No tens cap ordinador connectat</b><div class="muted small">Aquests ajustos es desen a l’ordinador. Connecta’n un per activar-los.</div></div><button class="btn ink sm" data-act="add-computer" type="button">Connecta un ordinador</button></div>
|
|
733
|
+
${sec('Avisos al mòbil', 'Rep un avís quan un agent et demana permís o acaba la feina.', 'Activa els avisos')}
|
|
734
|
+
${sec('Veu', 'Dicta els missatges amb el micròfon, gratis amb Whisper, i escolta les respostes en veu alta.', 'Configura la veu')}
|
|
735
|
+
${sec('Telegram', 'Parla amb els agents des del teu bot de Telegram: instruccions, notes de veu, avisos i aprovacions amb botons.', 'Connecta el bot')}`;
|
|
736
|
+
}
|
|
650
737
|
function viewHelp() {
|
|
651
738
|
return `<div class="top"><div><h1>Ajuda</h1><p>Com es fa cada cosa i què mirar si no connecta.</p></div><button class="btn ink" type="button" data-act="tour">Fes el tutorial</button></div><div style="max-width:720px">${helpPanel()}</div>`;
|
|
652
739
|
}
|
|
653
740
|
function viewComputers() {
|
|
654
741
|
const ms = store.machines, m = machine(), info = state.info;
|
|
655
|
-
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
|
|
742
|
+
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>${ms.length ? `<button class="btn ink" id="addComputer" data-act="add-computer" type="button">${svg(ICON.plus)}Afegeix ordinador</button>` : ''}</div>`;
|
|
656
743
|
if (!ms.length) {
|
|
657
|
-
const
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
744
|
+
const phone = isPhone();
|
|
745
|
+
const steps = phone
|
|
746
|
+
? [['Enganxa-la a l’ordinador', 'Al Terminal. Instal·la nio i el deixa engegat, també quan reinicies.'], ['Escaneja el QR', 'Surt al navegador de l’ordinador quan acaba. També hi ha un codi.'], ['Dona feina a un agent', 'Tria l’agent i la carpeta, i mira com treballa des d’aquí.']]
|
|
747
|
+
: [['Obre el Terminal', 'Al Mac, ⌘ Espai i escriu «Terminal». A Linux, Ctrl Alt T.'], ['Enganxa l’ordre', 'Instal·la nio i el deixa engegat, també quan reinicies.'], ['Escaneja el QR', 'Aquesta web es connecta sola i et mostra el QR del mòbil.']];
|
|
748
|
+
return `${head}<div class="panel empty first-run">${bot('idle')}<h2>Connecta el teu primer ordinador</h2><p>${phone ? 'nio s’instal·la a l’ordinador amb una sola ordre. Copia-la i enganxa-la allà.' : 'Una sola ordre instal·la nio en aquest ordinador i ho deixa tot a punt.'}</p>${cmdBox(INSTALL_CMD)}<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><div class="row" style="justify-content:center"><button class="btn ink" data-act="add-computer" type="button">${svg(ICON.plus)}${phone ? 'Tinc un codi o un QR' : 'Afegeix ordinador'}</button><button class="btn" data-act="demo" type="button">Prova una demo</button></div><p class="muted small">Mac o Linux. Per provar-lo sense instal·lar res, <span class="mono">npx nio-cat</span>.</p></div>`;
|
|
661
749
|
}
|
|
662
750
|
const detail = m && state.connected ? `<h2 class="sec">${esc(m.name)}</h2>
|
|
663
751
|
<div class="grid2 one">
|
|
@@ -731,8 +819,8 @@ function addComputerSheet(tab, opts = {}) {
|
|
|
731
819
|
const d = await probeLocal(); if (cur !== 'local') return;
|
|
732
820
|
const saved = d && store.machines.findIndex((x) => x.id === d.id);
|
|
733
821
|
if (d && saved >= 0) set('saved', `${intro('Aquest ordinador ja és a la llista', 'El tens connectat com a «' + esc(d.name) + '».')}<div class="row"><button class="btn ink" type="button" data-open-saved="${saved}">Obre sessions</button></div>`);
|
|
734
|
-
else if (d) set('found', `${intro('nio funciona en aquest ordinador', 'Aquest navegador encara no hi té accés.
|
|
735
|
-
else set('none', `${intro('
|
|
822
|
+
else if (d) set('found', `${intro('nio ja funciona en aquest ordinador', 'Aquest navegador encara no hi té accés. Escriu això al Terminal i s’obrirà el panell ja connectat:')}${cmdBox('nio pair')}`);
|
|
823
|
+
else set('none', `${intro('Instal·la nio en aquest ordinador', 'Una sola ordre ho deixa tot a punt i engegat, també quan reinicies.')}${todo(['Obre el Terminal. Al Mac, prem ⌘ Espai i escriu «Terminal».', `Enganxa aquesta ordre i prem Retorn:${cmdBox(INSTALL_CMD)}`, 'Quan acabi, aquesta pàgina es connectarà sola.'])}${live('Esperant nio…')}<p class="hint">Mac o Linux. Si ja el tens i està aturat, escriu <span class="mono">nio</span> al Terminal.</p>${store.machines.length ? '' : '<div class="or"><span>o</span></div><button class="btn block" type="button" data-close data-act="demo">Prova una demo, sense instal·lar res</button>'}`);
|
|
736
824
|
if (saved < 0 || !d) poll = setTimeout(local, 3000);
|
|
737
825
|
};
|
|
738
826
|
const runSsh = async (target) => {
|
|
@@ -760,7 +848,7 @@ function addComputerSheet(tab, opts = {}) {
|
|
|
760
848
|
<ul class="checks"><li>Accés per SSH sense contrasenya (clau SSH)</li><li>Node.js 18 o més nou</li><li>Claude Code instal·lat i amb sessió iniciada</li></ul>`);
|
|
761
849
|
};
|
|
762
850
|
const code = () => {
|
|
763
|
-
set('code', `${intro(phone ? 'Escaneja o escriu el codi' : 'Escriu el codi', 'Surt al
|
|
851
|
+
set('code', `${intro(phone ? 'Escaneja o escriu el codi' : 'Escriu el codi', 'Surt al navegador de l’ordinador quan instal·les nio, o a «Continua al mòbil».')}
|
|
764
852
|
<form class="field-row" id="pairForm"><input type="text" id="pairInput" placeholder="nio-…" autocomplete="off" autocapitalize="off" spellcheck="false" required aria-label="Codi de connexió"><button class="btn ink" type="submit">Connecta</button></form>
|
|
765
853
|
${canScan ? `<div class="or"><span>o</span></div><button class="btn block" type="button" data-scan>${svg(ICON.scan)}Escaneja un QR</button><video class="scan" id="scanVideo" playsinline muted hidden></video>` : phone ? '<p class="hint">Per escanejar, obre la càmera del mòbil i apunta al QR: s’obrirà nio.</p>' : ''}`);
|
|
766
854
|
};
|
|
@@ -803,7 +891,8 @@ function renameSheet(i) {
|
|
|
803
891
|
});
|
|
804
892
|
}
|
|
805
893
|
async function pantallaTab() {
|
|
806
|
-
if (!machine()
|
|
894
|
+
if (!machine()) return openSheet(`<h2>Pantalla</h2><div class="stack"><p class="small"><b>No tens cap ordinador connectat.</b> Connecta’t a un ordinador per veure’n la pantalla i controlar-lo des del mòbil.</p><div class="row between sheet-foot"><button class="btn ghost" type="button" data-close>Ara no</button><button class="btn ink" type="button" data-act="add-computer">Connecta un ordinador</button></div></div>`, (sh) => { $('[data-close]', sh).onclick = closeSheet; });
|
|
895
|
+
if (!state.connected) return toast(`No arribo a ${machine().name}. Comprova que nio hi estigui engegat.`, 4500);
|
|
807
896
|
let caps = state.info?.control;
|
|
808
897
|
try { caps = await api('/api/control'); if (state.info) state.info.control = caps; } catch (e) { return toast(e.message, 4500); }
|
|
809
898
|
const d = caps?.escriptori;
|
|
@@ -823,7 +912,7 @@ async function pantallaTab() {
|
|
|
823
912
|
}
|
|
824
913
|
function moreSheet() {
|
|
825
914
|
const item = (k) => `<a class="card row" href="${HASH[k]}" style="gap:12px">${svg(VIEWS[k].icon)}<b>${VIEWS[k].label}</b></a>`;
|
|
826
|
-
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(); }; });
|
|
915
|
+
openSheet(`<h2>Més</h2><div class="list">${['notes', 'feines', 'memoria', 'mobil', 'pissarra', 'activitat', 'ajustos', 'ajuda'].map(item).join('')}</div>`, (sh) => { sh.onclick = (e) => { if (e.target.closest('a, [data-act]')) closeSheet(); }; });
|
|
827
916
|
}
|
|
828
917
|
|
|
829
918
|
/* ---------- Sessions */
|
|
@@ -834,11 +923,11 @@ function stateChip(s) {
|
|
|
834
923
|
return '';
|
|
835
924
|
}
|
|
836
925
|
let manage = null; // Set d'ids seleccionats quan el mode «Gestiona» és actiu
|
|
837
|
-
const DAY = 864e5
|
|
926
|
+
const DAY = 864e5;
|
|
838
927
|
const manageBar = (list) => {
|
|
839
|
-
const n = manage.size
|
|
928
|
+
const n = manage.size;
|
|
840
929
|
return `<div class="manage-bar"><div class="row"><span class="muted small">Selecciona</span><button class="chip" type="button" data-man="all">Totes</button><button class="chip" type="button" data-man="idle">Acabades</button><button class="chip" type="button" data-man="old">Més de 30 dies</button><button class="chip" type="button" data-man="err">Amb error</button><button class="chip" type="button" data-man="none">Cap</button></div>
|
|
841
|
-
<div class="row between"><span class="muted small">${n ? `${n} seleccionades
|
|
930
|
+
<div class="row between"><span class="muted small">${n ? `${n} seleccionades` : `${list.length} sessions`}</span><span class="row"><button class="btn ghost" type="button" data-act="manage-off">Fet</button><button class="btn danger" type="button" data-act="manage-del" ${n ? '' : 'disabled'}>Esborra${n ? ` (${n})` : ''}</button></span></div></div>`;
|
|
842
931
|
};
|
|
843
932
|
function manageSelect(kind) {
|
|
844
933
|
const list = [...state.sessions.values()], now = Date.now();
|
|
@@ -856,6 +945,7 @@ async function manageDelete() {
|
|
|
856
945
|
}
|
|
857
946
|
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();
|
|
858
947
|
function viewSessions() {
|
|
948
|
+
if (!machine()) return `<div class="top"><div><h1>Sessions</h1><p>Cada sessió és una conversa amb un agent, dins d’una carpeta.</p></div></div>${noMachine('Connecta’n un per donar feina als agents i seguir-ne les sessions en directe.')}`;
|
|
859
949
|
const list = [...state.sessions.values()].sort((a, b) => b.updated - a.updated);
|
|
860
950
|
const many = store.machines.length > 1, mach = machine();
|
|
861
951
|
const switcher = many ? `<button class="chip switch-chip" type="button" data-act="machines"><i class="dot ${state.connected ? 'on' : ''}"></i>${esc(mach.name)}${svg(ICON.chev)}</button>` : '';
|
|
@@ -863,15 +953,15 @@ function viewSessions() {
|
|
|
863
953
|
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><span class="row">${list.length && state.connected && !manage ? '<button class="btn" data-act="manage-on" type="button">Gestiona</button>' : ''}${manage ? '' : `<button class="btn ink new-fab" data-act="new" type="button">${svg(ICON.plus)}Nova sessió</button>`}</span></div>${hint}${manage && list.length ? manageBar(list) : ''}`;
|
|
864
954
|
if (machine() && !state.synced && !state.lastError && !list.length) return `${head}<div class="list" aria-busy="true">${[0, 1, 2].map(() => '<div class="card skel-card"><i class="skel" style="width:34%"></i><i class="skel tall" style="width:78%"></i><i class="skel" style="width:56%"></i></div>').join('')}</div>`;
|
|
865
955
|
if (!state.connected && !list.length) {
|
|
866
|
-
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>`;
|
|
956
|
+
return `${head}${queueList()}<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>` : ''}<button class="btn ghost" data-act="new" type="button">Posa una tasca a la cua</button><a class="btn ghost" href="#/ordinadors">Ordinadors</a></div>` : ''}</div>`;
|
|
867
957
|
}
|
|
868
958
|
if (manage) for (const id of [...manage]) if (!state.sessions.has(id)) manage.delete(id);
|
|
869
|
-
return `${head}${list.length ? `<div class="list">${list.map((s) => `
|
|
959
|
+
return `${head}${queueList()}${list.length ? `<div class="list">${list.map((s) => `
|
|
870
960
|
<${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' : ''}">
|
|
871
961
|
<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>
|
|
872
962
|
<div class="title">${esc(s.title || 'Sessió nova')}</div>
|
|
873
963
|
${s.state === 'error' && s.error ? `<div class="last err">${esc(s.error)}</div>` : s.last ? `<div class="last">${esc(plain(s.last))}</div>` : ''}
|
|
874
|
-
<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
|
|
964
|
+
<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></div>
|
|
875
965
|
</${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>`}`;
|
|
876
966
|
}
|
|
877
967
|
|
|
@@ -903,6 +993,7 @@ async function revokeDevice(id) {
|
|
|
903
993
|
let activity = null, actFilter = 'tot';
|
|
904
994
|
const ACT_FILTERS = { tot: 'Tot', approval: 'Aprovacions', tool: 'Eines', job: 'Feines', device: 'Dispositius' };
|
|
905
995
|
function viewActivity() {
|
|
996
|
+
if (!machine()) return `<div class="top"><div><h1>Activitat</h1><p>Tot el que fan els agents: què, per què, quan i qui ho ha aprovat.</p></div></div>${noMachine('El registre es guarda a cada ordinador. Connecta’n un per veure què hi han fet els agents.')}`;
|
|
906
997
|
return `<div class="top"><div><h1>Activitat</h1><p>Tot el que han fet els agents en aquest ordinador: què, per què, quan i qui ho ha aprovat. Es guarda només a l’ordinador.</p></div><button class="btn" data-act="activity-refresh" type="button">${svg(ICON.refresh)}Actualitza</button></div>
|
|
907
998
|
<div class="seg act-filter" role="group">${Object.entries(ACT_FILTERS).map(([k, l]) => `<button type="button" data-filter="${k}" aria-pressed="${k === actFilter}">${l}</button>`).join('')}</div>
|
|
908
999
|
<div id="actList">${activityHtml()}</div>`;
|
|
@@ -929,6 +1020,7 @@ async function loadActivity() {
|
|
|
929
1020
|
/* ---------- Memòria compartida (fitxers markdown a l'ordinador, per a Claude Code i Codex) */
|
|
930
1021
|
let mem = null;
|
|
931
1022
|
function viewMemory() {
|
|
1023
|
+
if (!machine()) return `<div class="top"><div><h1>Memòria</h1><p>El que recorden els teus agents: preferències, decisions i context dels projectes.</p></div></div>${noMachine('La memòria són fitxers a l’ordinador. Connecta’n un per veure-la i editar-la.')}`;
|
|
932
1024
|
return `<div class="top"><div><h1>Memòria</h1><p>El que recorden els teus agents en aquest ordinador. Claude Code i Codex la llegeixen i hi escriuen (servidor MCP de nio). Són fitxers markdown a <span class="mono">~/.nio/memory</span>.</p></div><button class="btn ink" data-act="mem-new" type="button">${svg(ICON.plus)}Fitxer nou</button></div>
|
|
933
1025
|
<form class="mem-search" id="memSearch" role="search"><input type="search" id="memQ" placeholder="Busca a la memòria…" aria-label="Busca a la memòria" autocomplete="off"><button class="btn ink" type="submit">Busca</button></form>
|
|
934
1026
|
<div id="memBody">${memHtml()}</div>`;
|
|
@@ -993,6 +1085,7 @@ function jobCard(j) {
|
|
|
993
1085
|
</div>`;
|
|
994
1086
|
}
|
|
995
1087
|
function viewJobs() {
|
|
1088
|
+
if (!machine()) return `<div class="top"><div><h1>Feines</h1><p>Encarrega feines que es fan soles: ara, més tard o cada dia.</p></div></div>${noMachine('Les feines s’executen a l’ordinador. Connecta’n un per programar-ne.')}`;
|
|
996
1089
|
const head = `<div class="top"><div><h1>Feines</h1><p>Encarrega feines que es fan soles: ara, més tard o cada dia, i encadena-les («quan Claude Code acabi, que Codex ho revisi»). Les fan Claude Code o Codex a l’ordinador.</p></div><button class="btn ink" data-act="job-new" type="button">${svg(ICON.plus)}Nova feina</button></div>`;
|
|
997
1090
|
if (!jobsData) return `${head}<div id="jobsBody"><div class="panel empty">${bot('work')}<h2>Carregant…</h2></div></div>`;
|
|
998
1091
|
return `${head}<div id="jobsBody">${jobsHtml()}</div>`;
|
|
@@ -1098,6 +1191,7 @@ function approvalCard(a, showSession = true) {
|
|
|
1098
1191
|
</div>`;
|
|
1099
1192
|
}
|
|
1100
1193
|
function viewApprovals() {
|
|
1194
|
+
if (!machine()) return `<div class="top"><div><h1>Aprovacions</h1><p>Quan un agent vol editar un fitxer o executar una ordre, t’ho demana aquí.</p></div></div>${noMachine('Quan un agent d’un ordinador teu necessiti permís, la petició t’arribarà aquí.')}`;
|
|
1101
1195
|
const list = [...state.approvals.values()].sort((a, b) => a.created - b.created);
|
|
1102
1196
|
return `<div class="top"><div><h1>Aprovacions</h1><p>Quan un agent vol editar un fitxer o executar una ordre, t’ho demana aquí.</p></div></div>
|
|
1103
1197
|
${machine() && !state.synced && !state.lastError && !list.length ? '<div class="list" aria-busy="true"><div class="card skel-card"><i class="skel" style="width:40%"></i><i class="skel tall" style="width:80%"></i><i class="skel" style="width:60%"></i></div></div>' : list.length ? `<div class="list">${list.map((a) => approvalCard(a)).join('')}</div>` : `<div class="panel empty">${bot('happy')}<h2>No hi ha res pendent</h2><p>Les sessions en mode «Demana permís» et faran les peticions aquí, i també t’avisaran al mòbil.</p></div>`}`;
|
|
@@ -1147,7 +1241,8 @@ function renderSession() {
|
|
|
1147
1241
|
<div class="feed" id="feed" aria-live="polite"></div>
|
|
1148
1242
|
<div class="composer" id="composer"></div>
|
|
1149
1243
|
</section>`;
|
|
1150
|
-
feedEls = new Map(); toolEls = new Map(); typers.clear();
|
|
1244
|
+
feedEls = new Map(); toolEls = new Map(); typers.clear(); pin.node = null; pinNext = false; pendingUser = null;
|
|
1245
|
+
composerRO?.disconnect(); watchComposer($('#composer'));
|
|
1151
1246
|
renderSessionHead();
|
|
1152
1247
|
const feed = $('#feed'), evs = state.events.get(id);
|
|
1153
1248
|
if (!evs) feed.innerHTML = machine() ? '<div class="skel-feed" aria-busy="true"><i class="skel r" style="width:46%"></i><i class="skel" style="width:88%;height:64px"></i><i class="skel" style="width:70%;height:40px"></i></div>' : `<div class="ev-info">${ 'Connecta un ordinador per veure aquesta sessió.'}</div>`;
|
|
@@ -1156,6 +1251,13 @@ function renderSession() {
|
|
|
1156
1251
|
requestAnimationFrame(() => toBottom(feed));
|
|
1157
1252
|
}
|
|
1158
1253
|
function sessionMood(s) { return !s ? 'idle' : s.state === 'running' ? 'work' : s.state === 'waiting' || s.pending ? 'peek' : s.state === 'error' ? 'sleep' : 'idle'; }
|
|
1254
|
+
// Ús de la subscripció de l'agent (5 h i setmana), com a esfis. Només si l'agent l'ha informat.
|
|
1255
|
+
const hhmm = (t) => new Date(t * 1000).toLocaleString('ca', { weekday: t * 1000 - Date.now() > 864e5 ? 'short' : undefined, hour: '2-digit', minute: '2-digit' });
|
|
1256
|
+
function usageChip(agent) {
|
|
1257
|
+
const u = state.usage?.[agent]; if (!u?.windows?.length) return '';
|
|
1258
|
+
const tip = u.windows.map((w) => `${w.label}: ${Math.round(w.used * 100)} %${w.resetsAt ? ` · es reinicia ${hhmm(w.resetsAt)}` : ''}`).join('\n');
|
|
1259
|
+
return `<span class="chip mono usage" title="${esc(`Ús de la subscripció\n${tip}`)}">${svg(CHIP_ICON.usage)}${u.windows.map((w) => `<span class="uw" data-hi="${w.used >= 0.8 ? 1 : 0}">${esc(w.label)} ${Math.round(w.used * 100)}%</span>`).join('')}</span>`;
|
|
1260
|
+
}
|
|
1159
1261
|
function renderSessionHead() {
|
|
1160
1262
|
renderChrome();
|
|
1161
1263
|
const s = state.sessions.get(state.route.id), bar = $('#modeBar');
|
|
@@ -1169,7 +1271,7 @@ function renderSessionHead() {
|
|
|
1169
1271
|
${ag?.noModes ? '' : `<button class="chip" data-act="mode" type="button">${svg(CHIP_ICON.mode)}<span>${esc(MODES[s.mode]?.label || s.mode)}</span></button>`}
|
|
1170
1272
|
<button class="chip mono" data-act="folder" type="button" title="${esc(s.cwd)} · canvia de carpeta">${svg(CHIP_ICON.folder)}<span>${esc(basename(s.cwd))}</span></button>
|
|
1171
1273
|
<button class="chip mono" data-act="model" type="button" title="Canvia de model">${svg(CHIP_ICON.model)}<span>${esc(s.model || 'Model per defecte')}</span></button>
|
|
1172
|
-
${
|
|
1274
|
+
${usageChip(s.agent)}
|
|
1173
1275
|
${stateChip(s)}`;
|
|
1174
1276
|
}
|
|
1175
1277
|
// Alçada del camp de text segons el contingut, sense barra de desplaçament fins al límit.
|
|
@@ -1183,6 +1285,45 @@ function autogrow(ta, max = 180) {
|
|
|
1183
1285
|
const scroller = (f) => (f && /(auto|scroll)/.test(getComputedStyle(f).overflowY) ? f : document.scrollingElement);
|
|
1184
1286
|
const nearBottom = (f) => { const s = scroller(f), h = s === document.scrollingElement ? innerHeight : s.clientHeight; return s.scrollHeight - s.scrollTop - h < 140; };
|
|
1185
1287
|
const toBottom = (f) => { const s = scroller(f); s.scrollTop = s.scrollHeight; };
|
|
1288
|
+
/* En enviar un missatge, puja suaument fins a quedar just sota els xips, i a sota queda l'espai per a la resposta
|
|
1289
|
+
(com més llarga, menys espai de més). Quan la resposta ja no hi cap, la conversa segueix el final com sempre. */
|
|
1290
|
+
const pin = { node: null, until: 0 };
|
|
1291
|
+
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
|
|
1292
|
+
function feedLines(feed) {
|
|
1293
|
+
const chat = feed.closest('.chat'), bar = $('.mode-bar', chat), comp = $('.composer', chat);
|
|
1294
|
+
return { top: (bar ? bar.getBoundingClientRect().bottom : feed.getBoundingClientRect().top) + 12, bottom: comp ? comp.getBoundingClientRect().top : innerHeight };
|
|
1295
|
+
}
|
|
1296
|
+
// Posició a la pantalla sense comptar les animacions d'entrada (transform), que mourien el càlcul uns quants píxels.
|
|
1297
|
+
function layTop(n) {
|
|
1298
|
+
const op = n.offsetParent || document.body;
|
|
1299
|
+
return op.getBoundingClientRect().top + (op.clientTop || 0) + n.offsetTop - (op === document.body ? 0 : op.scrollTop);
|
|
1300
|
+
}
|
|
1301
|
+
function sizeSpace(feed) {
|
|
1302
|
+
if (!feed) return;
|
|
1303
|
+
if (!pin.node || !feed.contains(pin.node)) { pin.node = null; feed.style.paddingBottom = ''; delete feed.dataset.pad; return; }
|
|
1304
|
+
// El marge de baix original es llegeix un sol cop: treure'l per mesurar faria saltar el scroll a dalt.
|
|
1305
|
+
if (!feed.dataset.pad) feed.dataset.pad = parseFloat(getComputedStyle(feed).paddingBottom) || 24;
|
|
1306
|
+
const base = +feed.dataset.pad, last = feed.lastElementChild;
|
|
1307
|
+
const { top, bottom } = feedLines(feed), span = layTop(last) + last.offsetHeight - layTop(pin.node);
|
|
1308
|
+
feed.style.paddingBottom = `${base + Math.max(0, bottom - top - span - base)}px`;
|
|
1309
|
+
}
|
|
1310
|
+
function pinMessage(feed, node) {
|
|
1311
|
+
pin.node = node; sizeSpace(feed);
|
|
1312
|
+
const delta = layTop(node) - feedLines(feed).top;
|
|
1313
|
+
pin.until = performance.now() + 650;
|
|
1314
|
+
scroller(feed).scrollBy({ top: delta, behavior: reducedMotion.matches ? 'auto' : 'smooth' });
|
|
1315
|
+
}
|
|
1316
|
+
// Després de cada canvi a la conversa: recalcula l'espai i, si eres al final, el segueix (mai a mig de la pujada).
|
|
1317
|
+
function follow(feed, wasNear) {
|
|
1318
|
+
sizeSpace(feed); if (wasNear && performance.now() > pin.until) toBottom(feed);
|
|
1319
|
+
// I un cop més quan el navegador ha acabat de col·locar-ho tot.
|
|
1320
|
+
if (pin.node) requestAnimationFrame(() => { const near = nearBottom(feed); sizeSpace(feed); if (near && performance.now() > pin.until) toBottom(feed); });
|
|
1321
|
+
}
|
|
1322
|
+
let pinNext = false, pendingUser = null;
|
|
1323
|
+
// Si el camp de text canvia d'alçada (dues línies, el teclat…), l'espai es torna a calcular perquè el missatge no es mogui.
|
|
1324
|
+
const composerRO = typeof ResizeObserver !== 'undefined' ? new ResizeObserver((es) => { for (const x of es) { const feed = $('.feed', x.target.closest('.chat')); if (feed && pin.node) { const near = nearBottom(feed); sizeSpace(feed); if (near) toBottom(feed); } } }) : null;
|
|
1325
|
+
const watchComposer = (c) => { if (c) composerRO?.observe(c); };
|
|
1326
|
+
visualViewport?.addEventListener('resize', () => sizeSpace($('#feed') || $('#lfeed')));
|
|
1186
1327
|
// El text de l'agent surt paraula per paraula, tal com arriba, sense refer tota la bombolla a cada tros.
|
|
1187
1328
|
const typers = new Map();
|
|
1188
1329
|
function wordsAfter(text, i, n) {
|
|
@@ -1213,13 +1354,13 @@ const typeTick = (t) => function tick(now) {
|
|
|
1213
1354
|
const stick = nearBottom(feed);
|
|
1214
1355
|
t.shown = wordsAfter(e.text, t.shown, 1 + Math.floor(backlog / 80));
|
|
1215
1356
|
t.node.innerHTML = md(e.text.slice(0, t.shown));
|
|
1216
|
-
|
|
1357
|
+
follow(feed, stick);
|
|
1217
1358
|
}
|
|
1218
1359
|
}
|
|
1219
1360
|
if (!e.open && t.shown >= e.text.length) {
|
|
1220
1361
|
typers.delete(t.seq);
|
|
1221
1362
|
const node = eventNode(e); t.node.replaceWith(node); feedEls.set(e.seq, node);
|
|
1222
|
-
|
|
1363
|
+
const near = nearBottom(feed); renderTyping(); follow(feed, near);
|
|
1223
1364
|
return;
|
|
1224
1365
|
}
|
|
1225
1366
|
t.raf = requestAnimationFrame(tick);
|
|
@@ -1234,6 +1375,7 @@ function putEvent(e, scroll = true) {
|
|
|
1234
1375
|
const st = $('.st', t); st.className = `st ${e.error ? 'err' : 'ok'}`; st.textContent = e.error ? '✕' : '✓';
|
|
1235
1376
|
let pre = $('pre.out', t); if (!pre && e.output) { pre = el('<pre class="out"></pre>'); t.appendChild(pre); }
|
|
1236
1377
|
if (pre) pre.textContent = e.output;
|
|
1378
|
+
sizeSpace(feed);
|
|
1237
1379
|
return;
|
|
1238
1380
|
}
|
|
1239
1381
|
}
|
|
@@ -1243,12 +1385,14 @@ function putEvent(e, scroll = true) {
|
|
|
1243
1385
|
if (last && last.classList.contains('ev-logs')) { const pre = $('pre', last); pre.textContent += `\n${e.text}`; $('summary', last).textContent = `Registre del procés (${pre.textContent.split('\n').length} línies)`; return; }
|
|
1244
1386
|
}
|
|
1245
1387
|
const node = eventNode(e); if (!node) return;
|
|
1388
|
+
if (e.kind === 'user' && pendingUser && pendingUser.text === e.text && !feedEls.get(e.seq)) { pendingUser.node.replaceWith(node); feedEls.set(e.seq, node); pendingUser = null; renderTyping(); return; }
|
|
1246
1389
|
const old = feedEls.get(e.seq);
|
|
1247
1390
|
if (old) old.replaceWith(node); else { const typing = $('.typing', feed); typing ? feed.insertBefore(node, typing) : feed.appendChild(node); }
|
|
1248
1391
|
feedEls.set(e.seq, node);
|
|
1249
1392
|
if (e.kind === 'tool') toolEls.set(e.id, node);
|
|
1250
1393
|
renderTyping();
|
|
1251
|
-
if (scroll &&
|
|
1394
|
+
if (scroll && e.kind === 'user' && pinNext && !old) { pinNext = false; return pinMessage(feed, node); }
|
|
1395
|
+
if (scroll) follow(feed, stick);
|
|
1252
1396
|
}
|
|
1253
1397
|
function eventNode(e) {
|
|
1254
1398
|
const s = state.sessions.get(state.route.id);
|
|
@@ -1262,7 +1406,6 @@ function eventNode(e) {
|
|
|
1262
1406
|
case 'info': return el(`<div class="ev-info">${esc(e.text)}</div>`);
|
|
1263
1407
|
case 'result': {
|
|
1264
1408
|
const bits = [];
|
|
1265
|
-
if (typeof e.cost === 'number' && e.cost > 0) bits.push(`${e.cost.toFixed(3)} $`);
|
|
1266
1409
|
if (e.ms) bits.push(`${(e.ms / 1000).toFixed(1).replace('.', ',')} s`);
|
|
1267
1410
|
if (e.tokens?.output_tokens || e.tokens?.output) bits.push(`${e.tokens.output_tokens || e.tokens.output} tokens`);
|
|
1268
1411
|
if (e.error) bits.push(String(e.error));
|
|
@@ -1282,6 +1425,7 @@ function renderTyping() {
|
|
|
1282
1425
|
else if (t) t.remove();
|
|
1283
1426
|
if (!busy) $$('.msg-ai.cursor', feed).forEach((n) => n.classList.remove('cursor'));
|
|
1284
1427
|
$$('.mode-bar .abot .bot, #headBot .bot').forEach((b) => { b.dataset.mood = sessionMood(s); });
|
|
1428
|
+
if (pin.node) sizeSpace(feed);
|
|
1285
1429
|
}
|
|
1286
1430
|
/* ---------- Comandes amb «/» (les de nio i les de l'agent) */
|
|
1287
1431
|
const NIO_CMDS = [
|
|
@@ -1411,8 +1555,11 @@ function renderComposer() {
|
|
|
1411
1555
|
const c = text.match(/^\/([\w:.-]+)(?:\s+([\s\S]*))?$/), nio = c && NIO_CMDS.find((x) => x.name === c[1].toLowerCase());
|
|
1412
1556
|
ta.value = ''; grow(); drawSlash();
|
|
1413
1557
|
if (nio) return runCmd(s, nio.name, (c[2] || '').trim());
|
|
1558
|
+
// El missatge surt a la pantalla a l'instant; quan arriba l'esdeveniment real, el substitueix.
|
|
1559
|
+
const feed = $('#feed'); pinNext = true;
|
|
1560
|
+
if (feed) { const tmp = el(`<div class="msg-user">${esc(text)}</div>`), typing = $('.typing', feed); typing ? feed.insertBefore(tmp, typing) : feed.appendChild(tmp); pendingUser = { node: tmp, text }; pinNext = false; pinMessage(feed, tmp); }
|
|
1414
1561
|
try { await api(`/api/sessions/${s.id}/message`, { method: 'POST', body: { text } }); }
|
|
1415
|
-
catch (e) { ta.value = text; grow(); toast(e.message, 4200); }
|
|
1562
|
+
catch (e) { pinNext = false; pendingUser?.node.remove(); pendingUser = null; ta.value = text; grow(); toast(e.message, 4200); }
|
|
1416
1563
|
};
|
|
1417
1564
|
ta.addEventListener('keydown', (e) => {
|
|
1418
1565
|
if (items.length && !pop.hidden) {
|
|
@@ -1460,7 +1607,7 @@ function modeHelp(agentId, mode) {
|
|
|
1460
1607
|
// Assistent de 4 passos: agent, carpeta exacta, opcions i tasca. Capçalera i peu fixos; només el cos fa scroll.
|
|
1461
1608
|
async function newSessionSheet(preset = {}) {
|
|
1462
1609
|
if (!machine()) return go('#/sessions');
|
|
1463
|
-
if (!state.connected) return
|
|
1610
|
+
if (!state.connected) return queueSheet();
|
|
1464
1611
|
const agents = [...(state.info?.agents || [])].sort((x, y) => Number(!!y.available) - Number(!!x.available));
|
|
1465
1612
|
let projects = [];
|
|
1466
1613
|
try { projects = await api('/api/projects'); } catch (e) { return toast(e.message); }
|
|
@@ -1547,7 +1694,10 @@ async function newSessionSheet(preset = {}) {
|
|
|
1547
1694
|
if (!sel.text.trim()) { $('#firstMsg', sh)?.focus(); return toast('Explica a l’agent què ha de fer'); }
|
|
1548
1695
|
const btn = $('[data-start]', sh); btn.disabled = true;
|
|
1549
1696
|
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}`); }
|
|
1550
|
-
catch (e) {
|
|
1697
|
+
catch (e) {
|
|
1698
|
+
if (e.name === 'TypeError') { const { text, ...rest } = sel; queue.add({ ...rest, text: text.trim(), title: text.trim().slice(0, 60) }); closeSheet(); toast('L’ordinador no respon. La tasca és a la cua.', 4200); return render(); }
|
|
1699
|
+
toast(e.message, 4200); btn.disabled = false;
|
|
1700
|
+
}
|
|
1551
1701
|
};
|
|
1552
1702
|
sh.onclick = (e) => {
|
|
1553
1703
|
const t = e.target;
|
|
@@ -1693,7 +1843,7 @@ async function loadOllama(force) {
|
|
|
1693
1843
|
paintModels();
|
|
1694
1844
|
}
|
|
1695
1845
|
function ollamaSection() {
|
|
1696
|
-
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>
|
|
1846
|
+
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>No tens cap ordinador connectat</b><div class="muted small" style="margin-top:4px">Els models d’Ollama funcionen a l’ordinador i són més grans i capaços. Connecta’n un per veure’ls i descarregar-ne.</div><button class="btn block" style="margin-top:10px" type="button" data-act="add-computer">Connecta un ordinador</button></div></div>`;
|
|
1697
1847
|
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>`;
|
|
1698
1848
|
}
|
|
1699
1849
|
function pctOf(j) { return j.total ? Math.min(100, Math.round((j.completed / j.total) * 100)) : 0; }
|
|
@@ -1853,7 +2003,7 @@ const inLocalChat = () => !!(local.engine || local.ollama);
|
|
|
1853
2003
|
function bindLocal() {
|
|
1854
2004
|
const feed = $('#lfeed'); if (!feed) return;
|
|
1855
2005
|
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>`));
|
|
1856
|
-
toBottom(feed);
|
|
2006
|
+
pin.node = null; toBottom(feed); composerRO?.disconnect(); watchComposer($('.composer', feed.closest('.chat')));
|
|
1857
2007
|
const ta = $('#lmsg');
|
|
1858
2008
|
if (local.draft) { ta.value = local.draft; local.draft = ''; autogrow(ta); }
|
|
1859
2009
|
ta.addEventListener('input', () => autogrow(ta));
|
|
@@ -1903,14 +2053,14 @@ async function sendLocal() {
|
|
|
1903
2053
|
ta.value = ''; autogrow(ta);
|
|
1904
2054
|
const feed = $('#lfeed'); if (!local.messages.length) feed.innerHTML = '';
|
|
1905
2055
|
local.messages.push({ role: 'user', content: text });
|
|
1906
|
-
|
|
1907
|
-
const out = el('<div class="msg-ai cursor"></div>'); feed.appendChild(out);
|
|
2056
|
+
const mine = el(`<div class="msg-user">${esc(text)}</div>`); feed.appendChild(mine);
|
|
2057
|
+
const out = el('<div class="msg-ai cursor"></div>'); feed.appendChild(out); pinMessage(feed, mine);
|
|
1908
2058
|
local.busy = true; local.stopped = false; setLocalBusy(true); let acc = '';
|
|
1909
2059
|
try {
|
|
1910
2060
|
if (local.ollama) {
|
|
1911
2061
|
const r = await api('/api/ollama/chat', { method: 'POST', body: { model: local.ollama, messages: [{ role: 'system', content: LOCAL_SYSTEM }, ...local.messages.slice(-12)] } });
|
|
1912
2062
|
if (!local.stopped) acc = r.content || '';
|
|
1913
|
-
out.innerHTML = md(stripThink(acc));
|
|
2063
|
+
const near = nearBottom(feed); out.innerHTML = md(stripThink(acc)); follow(feed, near);
|
|
1914
2064
|
} else {
|
|
1915
2065
|
const chunks = await local.engine.chat.completions.create({
|
|
1916
2066
|
messages: [{ role: 'system', content: LOCAL_SYSTEM }, ...local.messages.slice(-8)], stream: true,
|
|
@@ -1918,7 +2068,7 @@ async function sendLocal() {
|
|
|
1918
2068
|
extra_body: { enable_thinking: false }
|
|
1919
2069
|
});
|
|
1920
2070
|
for await (const c of chunks) {
|
|
1921
|
-
acc += c.choices[0]?.delta?.content || ''; out.innerHTML = md(stripThink(acc));
|
|
2071
|
+
acc += c.choices[0]?.delta?.content || ''; const near = nearBottom(feed); out.innerHTML = md(stripThink(acc)); follow(feed, near);
|
|
1922
2072
|
if (local.stopped) break;
|
|
1923
2073
|
}
|
|
1924
2074
|
}
|
|
@@ -2445,7 +2595,7 @@ async function hydrateShots(root = document) {
|
|
|
2445
2595
|
}
|
|
2446
2596
|
}
|
|
2447
2597
|
document.addEventListener('click', async (e) => {
|
|
2448
|
-
const a = e.target.closest('[data-act], [data-speak], [data-mic], [data-copy], [data-decide], [data-machine], [data-forget], [data-rename], [data-reconnect], [data-lm], [data-man], [data-pick]');
|
|
2598
|
+
const a = e.target.closest('[data-act], [data-speak], [data-mic], [data-copy], [data-decide], [data-machine], [data-forget], [data-rename], [data-reconnect], [data-lm], [data-man], [data-pick], [data-qdrop]');
|
|
2449
2599
|
if (!a) return;
|
|
2450
2600
|
if (a.dataset.copy) {
|
|
2451
2601
|
try { await navigator.clipboard.writeText(a.dataset.copy); } catch { return toast(a.dataset.copy); }
|
|
@@ -2466,6 +2616,7 @@ document.addEventListener('click', async (e) => {
|
|
|
2466
2616
|
if (a.dataset.rename != null) return renameSheet(Number(a.dataset.rename));
|
|
2467
2617
|
if (a.dataset.forget != null) { if (confirm('Aquest dispositiu deixarà de tenir accés a aquest ordinador.')) forgetMachine(Number(a.dataset.forget)); return; }
|
|
2468
2618
|
if (a.dataset.lm) { if (local.loading) return; LS.set('nio.localModel', a.dataset.lm); return render(); }
|
|
2619
|
+
if (a.dataset.qdrop) { queue.drop(a.dataset.qdrop); return render(); }
|
|
2469
2620
|
switch (a.dataset.act) {
|
|
2470
2621
|
case 'new': return newSessionSheet();
|
|
2471
2622
|
case 'manage-on': manage = new Set(); return render();
|
|
@@ -2586,7 +2737,7 @@ const saveTour = () => LS.set(TOUR_KEY, { on: T.on, i: T.i, seen: T.seen });
|
|
|
2586
2737
|
const PAGE = '.page .top, .top';
|
|
2587
2738
|
const TSTEPS = [
|
|
2588
2739
|
{ 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' },
|
|
2589
|
-
{ route: 'ordinadors', target: '#addComputer', m: '#addComputer', title: 'Afegeix un ordinador', text: 'Hi ha tres maneres: aquest ordinador (
|
|
2740
|
+
{ route: 'ordinadors', target: '#addComputer, .first-run [data-act=add-computer]', m: '#addComputer, .first-run [data-act=add-computer]', title: 'Afegeix un ordinador', text: 'Hi ha tres maneres: aquest ordinador (amb l’ordre d’instal·lació), un servidor per SSH (<span class="mono">usuari@servidor</span>) o un codi. nio fa la resta.' },
|
|
2590
2741
|
{ 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.' },
|
|
2591
2742
|
{ 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.' },
|
|
2592
2743
|
{ 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.' },
|