@danieltmn/openbridge 0.2.0 → 0.4.0

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.
@@ -3,6 +3,8 @@
3
3
  var CFG = window.APP_CONFIG || {};
4
4
  var CSRF = CFG.csrf || '';
5
5
  var INITIAL_SESSION = typeof CFG.initialSession === 'number' ? CFG.initialSession : null;
6
+ var IS_ADMIN = String(CFG.userRole || 'user') === 'admin';
7
+ function isAdmin() { return IS_ADMIN; }
6
8
 
7
9
  var els = {
8
10
  body: document.body,
@@ -28,6 +30,8 @@ var els = {
28
30
  sendBtn: document.getElementById('sendBtn'),
29
31
  btnImg: document.getElementById('btnImg'),
30
32
  btnMic: document.getElementById('btnMic'),
33
+ btnTpl: document.getElementById('btnTpl'),
34
+ tplPanel: document.getElementById('tplPanel'),
31
35
  imgInput: document.getElementById('imgInput'),
32
36
  imgPreview: document.getElementById('imgPreview'),
33
37
  imgThumb: document.getElementById('imgThumb'),
@@ -45,6 +49,7 @@ var els = {
45
49
  btnSkin: document.getElementById('btnSkin'),
46
50
  skinPanel: document.getElementById('skinPanel'),
47
51
  viewProcs: document.getElementById('viewProcs'),
52
+ viewChanges: document.getElementById('viewChanges'),
48
53
  themeColor: document.getElementById('themeColor'),
49
54
  themeLink: document.getElementById('themeStylesheet'),
50
55
  statusbar: document.getElementById('statusbar'),
@@ -84,6 +89,12 @@ var els = {
84
89
  btnCancel: document.getElementById('btnCancel'),
85
90
  };
86
91
 
92
+ // Marca visual del usuario actual (admin en color de acento).
93
+ (function () {
94
+ var ub = document.getElementById('userBadge');
95
+ if (ub) ub.classList.toggle('admin', IS_ADMIN);
96
+ })();
97
+
87
98
  var ONLINE_MAX_AGE_MS = 60000;
88
99
  var PROC_TIMEOUT_MS = 45000;
89
100
  var POLL_BASE_MS = 4000; // refresco pasivo (sin nada pendiente)
@@ -980,6 +991,7 @@ function showView(name) {
980
991
  els.viewHistory.style.display = 'none';
981
992
  els.viewPreview.style.display = 'none';
982
993
  els.viewProcs.style.display = 'none';
994
+ els.viewChanges.style.display = 'none';
983
995
 
984
996
  if (name !== 'procs') stopProcView();
985
997
 
@@ -1046,6 +1058,12 @@ function showView(name) {
1046
1058
  var pf = sessionProcFolder();
1047
1059
  els.hSub.textContent = pf ? projectLabel(pf) : 'de la sesión abierta';
1048
1060
  enterProcsView();
1061
+ } else if (name === 'changes') {
1062
+ els.viewChanges.style.display = '';
1063
+ els.btnBack.style.display = 'none';
1064
+ els.sendForm.style.display = 'none';
1065
+ els.btnCmds.style.display = 'none';
1066
+ renderChangesView();
1049
1067
  }
1050
1068
 
1051
1069
  if (els.fabNew) els.fabNew.classList.toggle('show', name === 'home');
@@ -1098,6 +1116,7 @@ function composeOpenSheet() {
1098
1116
  function composeCloseSheet() {
1099
1117
  if (!els.sendForm) return;
1100
1118
  voiceStop();
1119
+ tplClose();
1101
1120
  els.sendForm.classList.remove('open');
1102
1121
  document.body.classList.remove('compose-open');
1103
1122
  if (els.imgPreview && els.imgPreview.parentNode === els.sendForm && els.composeOpen) {
@@ -1253,7 +1272,7 @@ function renderHome(sessions) {
1253
1272
  + '<div class="cmeta">' + busy + esc(s.model || 'sin modelo') + ' · ' + esc(s.agent || 'build') + '</div>'
1254
1273
  + (s.preview ? '<div class="cpreview">' + prev + '</div>' : '')
1255
1274
  + '</div>'
1256
- + '<button type="button" class="kebab" aria-label="Opciones" title="Opciones">⋮</button>'
1275
+ + (isAdmin() ? '<button type="button" class="kebab" aria-label="Opciones" title="Opciones">⋮</button>' : '')
1257
1276
  + '</div>';
1258
1277
  }
1259
1278
  html += '</div>';
@@ -1632,13 +1651,13 @@ function onMsgScroll() {
1632
1651
  // reemplaza solo ese nodo; lo demás no se toca.
1633
1652
  function msgSig(m) {
1634
1653
  return (m.status || '') + '|' + (m.text || '').length + '|' + (m.reasoning || '').length
1635
- + '|' + (m.canceled ? 1 : 0) + '|' + (m.agent || '') + '|' + (m.img ? m.img.length : 0);
1654
+ + '|' + (m.canceled ? 1 : 0) + '|' + (m.agent || '') + '|' + (m.author || '') + '|' + (m.img ? m.img.length : 0);
1636
1655
  }
1637
1656
 
1638
1657
  function msgNodeHtml(m, chatQuery) {
1639
1658
  var cls = m.role === 'user' ? 'mine' : 'theirs';
1640
1659
  if (m.status === 'canceled') cls += ' canceled';
1641
- var who = m.role === 'user' ? '❯ vos' : '● Agente';
1660
+ var who = m.role === 'user' ? '❯ ' + esc(m.author || 'vos') : '● Agente';
1642
1661
  if (m.agent === 'plan') cls += ' plan';
1643
1662
  var agentBadge = m.agent ? ' <span class="abadge ' + esc(m.agent) + '">' + esc(m.agent) + '</span>' : '';
1644
1663
  var stopBadge = m.canceled ? ' <span class="stopbadge">⏹ detenido</span>' : '';
@@ -2485,6 +2504,85 @@ if (els.btnMic) {
2485
2504
  }
2486
2505
  }
2487
2506
 
2507
+ // ---------------------------------------------------------------------------
2508
+ // Plantillas de prompts (atajos guardados en el navegador).
2509
+ // ---------------------------------------------------------------------------
2510
+ var TPL_KEY = 'ob_tpl';
2511
+
2512
+ function tplLoad() {
2513
+ try {
2514
+ var a = JSON.parse(localStorage.getItem(TPL_KEY) || '[]');
2515
+ if (!Array.isArray(a)) return [];
2516
+ return a.filter(function (x) { return typeof x === 'string' && x.trim() !== ''; }).slice(0, 30);
2517
+ } catch (e) { return []; }
2518
+ }
2519
+ function tplStore(list) {
2520
+ try { localStorage.setItem(TPL_KEY, JSON.stringify(list)); } catch (e) {}
2521
+ }
2522
+ function tplClose() {
2523
+ if (els.tplPanel) els.tplPanel.style.display = 'none';
2524
+ }
2525
+ function tplRender() {
2526
+ var list = tplLoad();
2527
+ var html = '';
2528
+ for (var i = 0; i < list.length; i++) {
2529
+ html += '<div class="tpl-row" data-tpl="' + i + '"><span class="tpl-text">' + esc(list[i]) + '</span>'
2530
+ + '<button type="button" class="tpl-del" data-del="' + i + '" title="Borrar plantilla" aria-label="Borrar plantilla">✕</button></div>';
2531
+ }
2532
+ if (!list.length) html += '<div class="tpl-empty">No hay plantillas. Escribí un prompt y tocá “guardar lo escrito”.</div>';
2533
+ html += '<button type="button" class="tpl-save" id="tplSave">+ guardar lo escrito</button>';
2534
+ els.tplPanel.innerHTML = html;
2535
+ }
2536
+ function tplOpen() {
2537
+ closeSlash();
2538
+ tplRender();
2539
+ els.tplPanel.style.display = 'block';
2540
+ }
2541
+ function tplInsert(text) {
2542
+ var cur = els.input.value;
2543
+ els.input.value = cur && cur.trim() !== '' ? (cur.replace(/\s+$/, '') + ' ' + text) : text;
2544
+ tplClose();
2545
+ autoGrow();
2546
+ els.input.focus();
2547
+ try { els.input.setSelectionRange(els.input.value.length, els.input.value.length); } catch (e) {}
2548
+ }
2549
+ function tplSaveCurrent() {
2550
+ var text = els.input.value.trim();
2551
+ if (!text) { toast('escribí un prompt primero', 'error'); return; }
2552
+ var list = tplLoad();
2553
+ if (list.indexOf(text) >= 0) { toast('esa plantilla ya existe', 'ok'); return; }
2554
+ list.unshift(text);
2555
+ tplStore(list.slice(0, 30));
2556
+ toast('plantilla guardada', 'ok');
2557
+ tplRender();
2558
+ }
2559
+
2560
+ if (els.btnTpl && els.tplPanel) {
2561
+ els.btnTpl.addEventListener('click', function () {
2562
+ if (state.currentId === null) return;
2563
+ if (els.tplPanel.style.display === 'block') tplClose();
2564
+ else tplOpen();
2565
+ });
2566
+ els.tplPanel.addEventListener('click', function (e) {
2567
+ var del = e.target.closest('.tpl-del');
2568
+ if (del) {
2569
+ e.stopPropagation();
2570
+ var list = tplLoad();
2571
+ list.splice(parseInt(del.getAttribute('data-del'), 10), 1);
2572
+ tplStore(list);
2573
+ tplRender();
2574
+ return;
2575
+ }
2576
+ if (e.target.closest('.tpl-save')) { tplSaveCurrent(); return; }
2577
+ var row = e.target.closest('.tpl-row');
2578
+ if (row) {
2579
+ var list2 = tplLoad();
2580
+ var t = list2[parseInt(row.getAttribute('data-tpl'), 10)];
2581
+ if (typeof t === 'string') tplInsert(t);
2582
+ }
2583
+ });
2584
+ }
2585
+
2488
2586
  // ---------------------------------------------------------------------------
2489
2587
  // Composer: textarea multilínea (Enter envía, Shift+Enter salta de línea)
2490
2588
  // y menú de comandos "/" con filtro, flechas y Enter.
@@ -2532,6 +2630,7 @@ els.input.addEventListener('input', function () {
2532
2630
  autoGrow();
2533
2631
  var v = els.input.value;
2534
2632
  if (state.view === 'chat' && v.charAt(0) === '/') {
2633
+ tplClose();
2535
2634
  slash.items = slashFilter(v);
2536
2635
  slash.sel = 0;
2537
2636
  renderSlash();
@@ -2564,7 +2663,8 @@ els.input.addEventListener('keydown', function (e) {
2564
2663
  }
2565
2664
  if (e.key === 'Escape') {
2566
2665
  e.stopPropagation();
2567
- closeSlash();
2666
+ if (els.tplPanel && els.tplPanel.style.display === 'block') tplClose();
2667
+ else closeSlash();
2568
2668
  return;
2569
2669
  }
2570
2670
  }
@@ -2602,6 +2702,7 @@ els.sendForm.addEventListener('submit', async function (ev) {
2602
2702
  els.input.value = '';
2603
2703
  autoGrow();
2604
2704
  closeSlash();
2705
+ tplClose();
2605
2706
  setPendingImage(null);
2606
2707
  await loadHistory(state.currentId, true);
2607
2708
  } else {
@@ -2881,6 +2982,133 @@ function filesJoin(name) {
2881
2982
  return state.filesPath ? state.filesPath + '/' + name : name;
2882
2983
  }
2883
2984
 
2985
+ // ---------------------------------------------------------------------------
2986
+ // Cambios del proyecto (git status/diff) vía la cola del puente.
2987
+ // ---------------------------------------------------------------------------
2988
+ function changesFolder() {
2989
+ return (state.currentSession && state.currentSession.folder) || '';
2990
+ }
2991
+
2992
+ function renderChangesView() {
2993
+ els.hTitle.textContent = 'cambios';
2994
+ var folder = changesFolder();
2995
+ els.hSub.textContent = folder ? projectLabel(folder) : 'de la sesión abierta';
2996
+ if (!folder) {
2997
+ els.viewChanges.innerHTML = '<div class="placeholder">Abrí un chat para ver los cambios de su proyecto.</div>';
2998
+ return;
2999
+ }
3000
+ if (!state.online) {
3001
+ els.viewChanges.innerHTML = '<div class="placeholder">El puente está apagado. Iniciá “OpenBridge” en tu PC.</div>';
3002
+ return;
3003
+ }
3004
+ els.viewChanges.innerHTML = '<div class="view-head"><h2>cambios sin commitear</h2>'
3005
+ + '<div class="view-sub">' + esc(projectLabel(folder)) + '</div></div>'
3006
+ + '<div class="placeholder">consultando git…</div>';
3007
+ loadChanges(folder);
3008
+ }
3009
+
3010
+ function chgStatusClass(st) {
3011
+ if (st === '??' || st === 'A') return 'add';
3012
+ if (st.indexOf('D') >= 0) return 'del';
3013
+ if (st.indexOf('M') >= 0 || st.indexOf('R') >= 0) return 'mod';
3014
+ return '';
3015
+ }
3016
+
3017
+ function renderDiff(text) {
3018
+ var max = 200000;
3019
+ var trunc = false;
3020
+ if (text.length > max) { text = text.slice(0, max); trunc = true; }
3021
+ var lines = text.split('\n');
3022
+ var html = '';
3023
+ for (var i = 0; i < lines.length; i++) {
3024
+ var l = lines[i];
3025
+ var cls = 'diff-line';
3026
+ if (l.charAt(0) === '+') cls += ' add';
3027
+ else if (l.charAt(0) === '-') cls += ' del';
3028
+ else if (l.indexOf('@@') === 0) cls += ' hunk';
3029
+ else if (l.indexOf('diff --git') === 0 || l.indexOf('index ') === 0 || l.indexOf('--- ') === 0 || l.indexOf('+++ ') === 0) cls += ' head';
3030
+ html += '<div class="' + cls + '">' + esc(l || ' ') + '</div>';
3031
+ }
3032
+ if (trunc) html += '<div class="diff-line head">… (diff recortado)</div>';
3033
+ return '<div class="diff">' + html + '</div>';
3034
+ }
3035
+
3036
+ function changesHead(folder, branch) {
3037
+ return '<div class="view-head"><h2>cambios sin commitear</h2><div class="view-sub">'
3038
+ + esc(projectLabel(folder)) + (branch ? ' · ' + esc(branch) : '') + '</div></div>';
3039
+ }
3040
+
3041
+ async function loadChanges(folder) {
3042
+ var st = await ocCommand('git_status', [folder], 15, 500);
3043
+ if (!st.ok) {
3044
+ els.viewChanges.innerHTML = changesHead(folder, '')
3045
+ + '<div class="placeholder">' + esc(st.error || 'no se pudo consultar git') + '</div>';
3046
+ return;
3047
+ }
3048
+ var status = st.data || {};
3049
+ var files = status.files || [];
3050
+ if (!files.length) {
3051
+ els.viewChanges.innerHTML = changesHead(folder, status.branch)
3052
+ + '<div class="placeholder">Sin cambios: el árbol está limpio.</div>';
3053
+ return;
3054
+ }
3055
+ var list = '<div class="chg-files">';
3056
+ for (var i = 0; i < files.length; i++) {
3057
+ var rev = (isAdmin() && files[i].status !== '??')
3058
+ ? '<button type="button" class="chg-rev" data-path="' + esc(files[i].path) + '" title="Revertir este archivo" aria-label="Revertir este archivo">↩</button>'
3059
+ : '';
3060
+ list += '<div class="chg-row"><span class="chg-st ' + chgStatusClass(files[i].status) + '">' + esc(files[i].status) + '</span>'
3061
+ + '<span class="chg-path">' + esc(files[i].path) + '</span>' + rev + '</div>';
3062
+ }
3063
+ list += '</div>';
3064
+ var tracked = files.some(function (f) { return f.status !== '??'; });
3065
+ var toolbar = (isAdmin() && tracked)
3066
+ ? '<div class="files-toolbar"><button type="button" class="linkbtn" id="chgRevert">↩ revertir cambios rastreados</button></div>'
3067
+ : '';
3068
+ els.viewChanges.innerHTML = changesHead(folder, status.branch) + toolbar + list + '<div class="placeholder">cargando diff…</div>';
3069
+
3070
+ var diff = await ocCommand('git_diff', [folder], 20, 700);
3071
+ var diffHtml;
3072
+ if (diff.ok) {
3073
+ var text = (diff.data && diff.data.diff) || '';
3074
+ diffHtml = text ? renderDiff(text) : '<div class="placeholder">Hay archivos nuevos o renombrados sin diff de contenido.</div>';
3075
+ } else {
3076
+ diffHtml = '<div class="placeholder">' + esc(diff.error || 'no se pudo obtener el diff') + '</div>';
3077
+ }
3078
+ els.viewChanges.innerHTML = changesHead(folder, status.branch) + toolbar + list + diffHtml;
3079
+ }
3080
+
3081
+ // Revertir cambios rastreados (con confirmacion): borra las modificaciones que
3082
+ // el agente u otros hicieron en archivos ya versionados. No toca los nuevos.
3083
+ // Sirve para un archivo puntual (boton de la fila) o para todos.
3084
+ if (els.viewChanges) {
3085
+ els.viewChanges.addEventListener('click', function (e) {
3086
+ if (!isAdmin()) return;
3087
+ var folder = changesFolder();
3088
+ if (!folder) return;
3089
+ var one = e.target.closest('.chg-rev');
3090
+ if (one) {
3091
+ var rel = one.getAttribute('data-path');
3092
+ if (!window.confirm('¿Revertir “' + rel + '”?\n\nNo se puede deshacer.')) return;
3093
+ one.disabled = true;
3094
+ ocCommand('git_checkout', [folder, rel], 15, 500).then(function (r) {
3095
+ toast(r.ok ? 'revertido: ' + rel : (r.error || 'no se pudo revertir'), r.ok ? 'ok' : 'error');
3096
+ renderChangesView();
3097
+ });
3098
+ return;
3099
+ }
3100
+ var all = e.target.closest('#chgRevert');
3101
+ if (!all) return;
3102
+ if (!window.confirm('¿Revertir TODOS los cambios de archivos rastreados en “' + projectLabel(folder) + '”?\n\nNo se puede deshacer. Los archivos nuevos sin seguimiento no se tocan.')) return;
3103
+ all.disabled = true;
3104
+ all.textContent = 'revirtiendo…';
3105
+ ocCommand('git_checkout', [folder], 15, 500).then(function (r) {
3106
+ toast(r.ok ? 'cambios revertidos' : (r.error || 'no se pudo revertir'), r.ok ? 'ok' : 'error');
3107
+ renderChangesView();
3108
+ });
3109
+ });
3110
+ }
3111
+
2884
3112
  function renderFilesView() {
2885
3113
  state.fileView = null;
2886
3114
  var ws = (state.catalog && state.catalog.workspace) || (state.filesWorkspace || '');
@@ -3178,21 +3406,26 @@ function renderPreviewView() {
3178
3406
  }
3179
3407
  els.viewPreview.innerHTML = '<div class="view-head"><h2>vista previa</h2>'
3180
3408
  + '<div class="view-sub">túneles del proyecto (TunnelMole, corren en tu PC)</div></div>'
3181
- + '<div class="tunnel-form">'
3182
- + '<input type="text" id="tunnelPort" inputmode="numeric" placeholder="puerto (ej: 8080)" value="' + esc(state.previewPort) + '" autocomplete="off">'
3183
- + '<button type="button" class="linkbtn" id="tunnelStart">abrir túnel</button>'
3184
- + '</div>'
3409
+ + (isAdmin()
3410
+ ? '<div class="tunnel-form">'
3411
+ + '<input type="text" id="tunnelPort" inputmode="numeric" placeholder="puerto (ej: 8080)" value="' + esc(state.previewPort) + '" autocomplete="off">'
3412
+ + '<button type="button" class="linkbtn" id="tunnelStart">abrir túnel</button>'
3413
+ + '</div>'
3414
+ : '')
3185
3415
  + '<div class="tnote-sec" style="margin:0 0 12px">los túneles son públicos en internet y viven mientras el puente esté corriendo · cerrálos al terminar</div>'
3186
3416
  + '<div id="tunnelList"></div>';
3187
3417
  var portInput = document.getElementById('tunnelPort');
3188
- portInput.addEventListener('input', function () {
3189
- state.previewPort = portInput.value.replace(/[^0-9]/g, '');
3190
- portInput.value = state.previewPort;
3191
- });
3192
- portInput.addEventListener('keydown', function (e) {
3193
- if (e.key === 'Enter') { e.preventDefault(); startTunnel(); }
3194
- });
3195
- document.getElementById('tunnelStart').addEventListener('click', startTunnel);
3418
+ if (portInput) {
3419
+ portInput.addEventListener('input', function () {
3420
+ state.previewPort = portInput.value.replace(/[^0-9]/g, '');
3421
+ portInput.value = state.previewPort;
3422
+ });
3423
+ portInput.addEventListener('keydown', function (e) {
3424
+ if (e.key === 'Enter') { e.preventDefault(); startTunnel(); }
3425
+ });
3426
+ var tunStartBtn = document.getElementById('tunnelStart');
3427
+ if (tunStartBtn) tunStartBtn.addEventListener('click', startTunnel);
3428
+ }
3196
3429
  renderTunnels();
3197
3430
  refreshTunnels();
3198
3431
  }
@@ -3209,7 +3442,8 @@ function renderTunnels(busyMsg) {
3209
3442
  + '<div class="thead"><span class="dot"' + (ready ? '' : ' style="background:var(--muted)"') + '></span>'
3210
3443
  + '<b>puerto ' + esc(t.port) + '</b>'
3211
3444
  + '<span class="tnote">' + (ready ? 'activo' : 'levantando…') + '</span>'
3212
- + '<button type="button" class="linkbtn" data-tstop="' + esc(t.port) + '">cerrar túnel</button></div>'
3445
+ + (isAdmin() ? '<button type="button" class="linkbtn" data-tstop="' + esc(t.port) + '">cerrar túnel</button>' : '')
3446
+ + '</div>'
3213
3447
  + (t.https ? '<div class="turl"><a href="' + esc(t.https) + '" target="_blank" rel="noopener noreferrer">' + esc(t.https) + '</a>'
3214
3448
  + '<button type="button" class="copybtn" data-tcopy="' + esc(t.https) + '" title="Copiar URL">⧉</button></div>' : '')
3215
3449
  + (t.http && t.http !== t.https ? '<div class="turl"><a href="' + esc(t.http) + '" target="_blank" rel="noopener noreferrer">' + esc(t.http) + '</a>'
@@ -3369,6 +3603,7 @@ function enterProcsView() {
3369
3603
  }
3370
3604
 
3371
3605
  function renderProcView(folder) {
3606
+ var admin = isAdmin();
3372
3607
  els.viewProcs.innerHTML =
3373
3608
  '<div class="pp-head"><b>procesos</b><span class="pp-sub">' + esc(sessionProcLabel(folder)) + '</span>'
3374
3609
  + (state.currentId !== null ? '<button type="button" class="linkbtn pp-back" id="ppBack">← volver al chat</button>' : '')
@@ -3376,35 +3611,42 @@ function renderProcView(folder) {
3376
3611
  + '<div class="pp-status" id="procStatus"></div>'
3377
3612
  + '<div id="ppPortWarn"></div>'
3378
3613
  + '<pre id="procLog"><span class="pl-empty">sin salida todavía</span></pre>'
3379
- + '<div class="pp-tunnel">'
3380
- + '<div class="pp-tunhead">compartir por túnel (tunnelmole, corre en tu PC)</div>'
3381
- + '<div class="pp-tunform"><input type="text" id="ppTunPort" inputmode="numeric" placeholder="puerto (ej: 3000)" autocomplete="off">'
3382
- + '<button type="button" class="linkbtn" id="ppTunStart">abrir túnel</button></div>'
3383
- + '<div id="ppTunList"></div>'
3384
- + '</div>'
3385
- + '<div class="pp-chips" id="procChips"></div>'
3386
- + '<div class="pp-form"><input type="text" id="procCmdInput" placeholder="npm run dev · node server.js …" autocomplete="off">'
3387
- + '<button type="button" class="linkbtn" id="procStartBtn">iniciar</button></div>';
3614
+ + (admin ? '<div class="pp-tunnel">'
3615
+ + '<div class="pp-tunhead">compartir por túnel (tunnelmole, corre en tu PC)</div>'
3616
+ + '<div class="pp-tunform"><input type="text" id="ppTunPort" inputmode="numeric" placeholder="puerto (ej: 3000)" autocomplete="off">'
3617
+ + '<button type="button" class="linkbtn" id="ppTunStart">abrir túnel</button></div>'
3618
+ + '<div id="ppTunList"></div>'
3619
+ + '</div>' : '')
3620
+ + (admin ? '<div class="pp-chips" id="procChips"></div>' : '')
3621
+ + (admin ? '<div class="pp-form"><input type="text" id="procCmdInput" placeholder="npm run dev · node server.js …" autocomplete="off">'
3622
+ + '<button type="button" class="linkbtn" id="procStartBtn">iniciar</button></div>' : '')
3623
+ + (!admin ? '<div class="placeholder">solo un admin puede iniciar procesos o túneles.</div>' : '');
3388
3624
  var back = document.getElementById('ppBack');
3389
3625
  if (back) back.addEventListener('click', function () { showView('chat'); });
3390
3626
  var inp = document.getElementById('procCmdInput');
3391
- inp.value = procState.input || '';
3392
- inp.addEventListener('input', function () { procState.input = inp.value; });
3393
- inp.addEventListener('keydown', function (e) {
3394
- if (e.key === 'Enter') { e.preventDefault(); startSessionProc(); }
3395
- });
3396
- document.getElementById('procStartBtn').addEventListener('click', startSessionProc);
3627
+ if (inp) {
3628
+ inp.value = procState.input || '';
3629
+ inp.addEventListener('input', function () { procState.input = inp.value; });
3630
+ inp.addEventListener('keydown', function (e) {
3631
+ if (e.key === 'Enter') { e.preventDefault(); startSessionProc(); }
3632
+ });
3633
+ }
3634
+ var startBtn = document.getElementById('procStartBtn');
3635
+ if (startBtn) startBtn.addEventListener('click', startSessionProc);
3397
3636
  var tunPort = document.getElementById('ppTunPort');
3398
- tunPort.value = procState.detectedPort || '';
3399
- tunPort.addEventListener('input', function () {
3400
- tunPort.value = tunPort.value.replace(/[^0-9]/g, '');
3401
- if (tunPort.value) state.previewPort = tunPort.value;
3402
- });
3403
- tunPort.addEventListener('keydown', function (e) {
3404
- if (e.key === 'Enter') { e.preventDefault(); panelStartTunnel(); }
3405
- });
3406
- document.getElementById('ppTunStart').addEventListener('click', panelStartTunnel);
3407
- buildProcChips();
3637
+ if (tunPort) {
3638
+ tunPort.value = procState.detectedPort || '';
3639
+ tunPort.addEventListener('input', function () {
3640
+ tunPort.value = tunPort.value.replace(/[^0-9]/g, '');
3641
+ if (tunPort.value) state.previewPort = tunPort.value;
3642
+ });
3643
+ tunPort.addEventListener('keydown', function (e) {
3644
+ if (e.key === 'Enter') { e.preventDefault(); panelStartTunnel(); }
3645
+ });
3646
+ }
3647
+ var tunStart = document.getElementById('ppTunStart');
3648
+ if (tunStart) tunStart.addEventListener('click', panelStartTunnel);
3649
+ if (admin) buildProcChips();
3408
3650
  procLogBox = document.getElementById('procLog');
3409
3651
  renderProcStatus();
3410
3652
  renderPortWarn();
@@ -3689,7 +3931,8 @@ function renderProcTunnels(busyMsg) {
3689
3931
  var ready = !!(t.https || t.http);
3690
3932
  html += '<div class="tunnel"><div class="thead"><span class="dot"' + (ready ? '' : ' style="background:var(--muted)"') + '></span>'
3691
3933
  + '<b>puerto ' + esc(t.port) + '</b><span class="tnote">' + (ready ? 'activo' : 'levantando…') + '</span>'
3692
- + '<button type="button" class="linkbtn" data-ptstop="' + esc(t.port) + '">cerrar</button></div>'
3934
+ + (isAdmin() ? '<button type="button" class="linkbtn" data-ptstop="' + esc(t.port) + '">cerrar</button>' : '')
3935
+ + '</div>'
3693
3936
  + (t.https ? '<div class="turl"><a href="' + esc(t.https) + '" target="_blank" rel="noopener noreferrer">' + esc(t.https) + '</a>'
3694
3937
  + '<button type="button" class="copybtn" data-ptcopy="' + esc(t.https) + '" title="Copiar URL">⧉</button></div>' : '')
3695
3938
  + (t.http && t.http !== t.https ? '<div class="turl"><a href="' + esc(t.http) + '" target="_blank" rel="noopener noreferrer">' + esc(t.http) + '</a>'
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "name": "OpenBridge",
3
3
  "short_name": "OpenBridge",
4
+ "id": "/",
5
+ "lang": "es",
4
6
  "description": "Chat con tu IA en el PC (opencode)",
5
7
  "start_url": "./chat.php",
6
8
  "scope": "./",
@@ -6,7 +6,7 @@
6
6
  */
7
7
  'use strict';
8
8
 
9
- const CACHE = 'openbridge-v3';
9
+ const CACHE = 'openbridge-v4';
10
10
 
11
11
  self.addEventListener('install', () => self.skipWaiting());
12
12
  self.addEventListener('activate', (event) => {