@danieltmn/openbridge 0.1.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/LICENSE +21 -0
  3. package/README.md +143 -0
  4. package/bin/openbridge.js +18 -0
  5. package/docs/ARCHITECTURE.md +77 -0
  6. package/package.json +54 -0
  7. package/src/auth.js +204 -0
  8. package/src/bridge/bridge.js +2280 -0
  9. package/src/cli.js +791 -0
  10. package/src/config.js +108 -0
  11. package/src/log.js +22 -0
  12. package/src/paths.js +152 -0
  13. package/src/push.js +85 -0
  14. package/src/store/index.js +930 -0
  15. package/src/store/jsonfile.js +54 -0
  16. package/src/tunnel/index.js +141 -0
  17. package/src/web/assets/app.js +3939 -0
  18. package/src/web/assets/icons/icon-128x128.png +0 -0
  19. package/src/web/assets/icons/icon-144x144.png +0 -0
  20. package/src/web/assets/icons/icon-152x152.png +0 -0
  21. package/src/web/assets/icons/icon-192x192.png +0 -0
  22. package/src/web/assets/icons/icon-384x384.png +0 -0
  23. package/src/web/assets/icons/icon-512x512.png +0 -0
  24. package/src/web/assets/icons/icon-72x72.png +0 -0
  25. package/src/web/assets/icons/icon-96x96.png +0 -0
  26. package/src/web/assets/manifest.webmanifest +24 -0
  27. package/src/web/assets/sw.js +85 -0
  28. package/src/web/assets/themes/dark-plus.css +19 -0
  29. package/src/web/assets/themes/dark-red.css +18 -0
  30. package/src/web/assets/themes/default.css +18 -0
  31. package/src/web/assets/themes/github-light.css +19 -0
  32. package/src/web/assets/themes/high-contrast.css +18 -0
  33. package/src/web/assets/themes/index.json +15 -0
  34. package/src/web/assets/themes/light-plus.css +19 -0
  35. package/src/web/assets/themes/light.css +18 -0
  36. package/src/web/assets/themes/monokai.css +19 -0
  37. package/src/web/assets/themes/one-dark.css +19 -0
  38. package/src/web/assets/themes/solarized.css +18 -0
  39. package/src/web/assets/themes/terminal.css +28 -0
  40. package/src/web/assets/themes/themes.css +2 -0
  41. package/src/web/routes.js +871 -0
  42. package/src/web/server.js +130 -0
  43. package/src/web/templates/chat.html +1296 -0
  44. package/src/web/templates/login.html +144 -0
@@ -0,0 +1,3939 @@
1
+ "use strict";
2
+
3
+ var CFG = window.APP_CONFIG || {};
4
+ var CSRF = CFG.csrf || '';
5
+ var INITIAL_SESSION = typeof CFG.initialSession === 'number' ? CFG.initialSession : null;
6
+
7
+ var els = {
8
+ body: document.body,
9
+ scrim: document.getElementById('scrim'),
10
+ btnBurger: document.getElementById('btnBurger'),
11
+ btnBack: document.getElementById('btnBack'),
12
+ sidebar: document.getElementById('sidebar'),
13
+ sideButtons: document.querySelectorAll('#sidebar .utilities button'),
14
+ sessionTree: document.getElementById('sessionTree'),
15
+ sideSearch: document.getElementById('sideSearch'),
16
+ bridgeBar: document.getElementById('bridgeBar'),
17
+ btnNewSession: document.getElementById('btnNewSession'),
18
+ bridgeDot: document.getElementById('bridgeDot'),
19
+ bridgeStatusText: document.getElementById('bridgeStatusText'),
20
+ envBadge: null,
21
+ hTitle: document.getElementById('hTitle'),
22
+ hSub: document.getElementById('hSub'),
23
+ home: document.getElementById('home'),
24
+ chat: document.getElementById('chat'),
25
+ messages: document.getElementById('messages'),
26
+ sendForm: document.getElementById('sendForm'),
27
+ input: document.getElementById('input'),
28
+ sendBtn: document.getElementById('sendBtn'),
29
+ btnImg: document.getElementById('btnImg'),
30
+ imgInput: document.getElementById('imgInput'),
31
+ imgPreview: document.getElementById('imgPreview'),
32
+ imgThumb: document.getElementById('imgThumb'),
33
+ imgRemove: document.getElementById('imgRemove'),
34
+ imgMeta: document.getElementById('imgMeta'),
35
+ jumpBtn: document.getElementById('jumpBtn'),
36
+ procrow: document.getElementById('procrow'),
37
+ composeOpen: document.getElementById('composeOpen'),
38
+ slashPanel: document.getElementById('slashPanel'),
39
+ cardMenu: document.getElementById('cardMenu'),
40
+ offline: document.getElementById('offline'),
41
+ btnCmds: document.getElementById('btnCmds'),
42
+ cmdsPanel: document.getElementById('cmdsPanel'),
43
+ btnPush: document.getElementById('btnPush'),
44
+ btnSkin: document.getElementById('btnSkin'),
45
+ skinPanel: document.getElementById('skinPanel'),
46
+ viewProcs: document.getElementById('viewProcs'),
47
+ themeColor: document.getElementById('themeColor'),
48
+ themeLink: document.getElementById('themeStylesheet'),
49
+ statusbar: document.getElementById('statusbar'),
50
+ sbDot: document.getElementById('sbDot'),
51
+ sbMode: document.getElementById('sbMode'),
52
+ sbModel: document.getElementById('sbModel'),
53
+ sbExtra: document.getElementById('sbExtra'),
54
+ fabNew: document.getElementById('fabNew'),
55
+ modelOverlay: document.getElementById('modelOverlay'),
56
+ mTitle: document.getElementById('mTitle'),
57
+ mSearch: document.getElementById('mSearch'),
58
+ mWarn: document.getElementById('mWarn'),
59
+ fModel2: document.getElementById('fModel2'),
60
+ fAgent2: document.getElementById('fAgent2'),
61
+ btnModelCancel: document.getElementById('btnModelCancel'),
62
+ btnModelSave: document.getElementById('btnModelSave'),
63
+ viewFiles: document.getElementById('viewFiles'),
64
+ viewSessions: document.getElementById('viewSessions'),
65
+ viewSearch: document.getElementById('viewSearch'),
66
+ viewHistory: document.getElementById('viewHistory'),
67
+ viewPreview: document.getElementById('viewPreview'),
68
+ brandHome: document.getElementById('brandHome'),
69
+ btnSbHide: document.getElementById('btnSbHide'),
70
+ sbReveal: document.getElementById('sbReveal'),
71
+ overlay: document.getElementById('overlay'),
72
+ fMsg: document.getElementById('fMsg'),
73
+ fFolder: document.getElementById('fFolder'),
74
+ fModelSearch: document.getElementById('fModelSearch'),
75
+ fFolderCreate: document.getElementById('fFolderCreate'),
76
+ fNewFolder: document.getElementById('fNewFolder'),
77
+ btnNewFolder: document.getElementById('btnNewFolder'),
78
+ fNewFolderMsg: document.getElementById('fNewFolderMsg'),
79
+ fModel: document.getElementById('fModel'),
80
+ fAgent: document.getElementById('fAgent'),
81
+ fWarn: document.getElementById('fWarn'),
82
+ btnSave: document.getElementById('btnSave'),
83
+ btnCancel: document.getElementById('btnCancel'),
84
+ };
85
+
86
+ var ONLINE_MAX_AGE_MS = 60000;
87
+ var PROC_TIMEOUT_MS = 45000;
88
+ var POLL_BASE_MS = 4000; // refresco pasivo (sin nada pendiente)
89
+ var POLL_FAST_MS = 2200; // refresco mientras hay un mensaje esperando respuesta
90
+ var CMDS = [
91
+ { cmd: '/new', desc: 'Empezar conversación nueva' },
92
+ { cmd: '/compact', desc: 'Liberar contexto' },
93
+ { cmd: '/models', desc: 'Proveedores y búsqueda de modelos' },
94
+ { cmd: '/agents', desc: 'Listar agentes disponibles' },
95
+ { cmd: '/folders', desc: 'Carpetas del workspace' },
96
+ { cmd: '/workspace', desc: 'Mostrar el espacio de trabajo' },
97
+ { cmd: '/status', desc: 'Estado del puente' },
98
+ { cmd: '/help', desc: 'Todos los comandos' },
99
+ ];
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Caché local (localStorage): pintado instantáneo con los últimos datos.
103
+ // ---------------------------------------------------------------------------
104
+ function lsGet(key) {
105
+ try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch (e) { return null; }
106
+ }
107
+ function lsSet(key, val) {
108
+ try { localStorage.setItem(key, JSON.stringify(val)); } catch (e) {}
109
+ }
110
+
111
+ var state = {
112
+ view: 'home',
113
+ currentId: null,
114
+ currentSession: null,
115
+ catalog: null,
116
+ catVer: '',
117
+ bridges: [], // resumen de puentes registrados [{id,name,online,busy_session}]
118
+ activeBridge: '', // puente seleccionado en el sidebar (id)
119
+ isSending: false,
120
+ pendingImage: null,
121
+ messages: [],
122
+ procTimer: null,
123
+ online: true,
124
+ theme: (CFG.theme || 'terminal'),
125
+ themes: (Array.isArray(CFG.themes) && CFG.themes.length
126
+ ? CFG.themes
127
+ : [{ slug: 'terminal', label: 'Terminal', sw: ['#0a0d12', '#3fb950'] }]),
128
+ sse: null,
129
+ sseBackoff: 1000,
130
+ pendingSessions: new Set(),
131
+ sessions: [],
132
+ sessionCount: 0,
133
+ openProjects: null, // Set de rutas de carpeta expandidas
134
+ sideQuery: '',
135
+ focusFolder: '', // proyecto activo en el sidebar (destacado)
136
+ focusFlash: null, // proyecto a expandir+resaltar (se consume en renderHome)
137
+ histFolder: null, // proyecto abierto en la vista historial
138
+ histLabel: '',
139
+ histQuery: '',
140
+ prevView: null, // vista desde la que se abrió el chat (para el botón atrás)
141
+ lastFolder: '',
142
+ renderSid: null,
143
+ lastDay: '',
144
+ sideSig: null,
145
+ procSince: 0,
146
+ filesPath: '',
147
+ filesEntries: [],
148
+ filesCache: {},
149
+ fileView: null,
150
+ searchHits: [],
151
+ tunnels: null,
152
+ previewPort: '',
153
+ proc: null, // estado del proceso de la sesión (procStart), ver procState
154
+ };
155
+
156
+ // Puente activo y lista conocida se restauran antes de pintar (caché local).
157
+ state.activeBridge = loadStoredBridge();
158
+ var _cachedBridges = lsGet('ocx_bridges');
159
+ if (Array.isArray(_cachedBridges) && _cachedBridges.length) state.bridges = _cachedBridges;
160
+
161
+ // Estado del panel "procesos" de la sesión activa (dev servers en la PC).
162
+ var procState = {
163
+ open: false,
164
+ id: null, // id del proceso en el puente
165
+ offset: 0, // cursor del log ya mostrado
166
+ log: '', // log acumulado (recortado a ~280 KB)
167
+ logRendered: 0,
168
+ logDropped: false,
169
+ running: false,
170
+ exitCode: null,
171
+ exitErr: '',
172
+ input: '', // última línea de comando (se conserva al re-render)
173
+ detectedPort: '', // puerto detectado en la salida (para el túnel)
174
+ portConflict: null, // puerto en uso detectado por EADDRINUSE en el log
175
+ };
176
+
177
+ function esc(t) {
178
+ var d = document.createElement('div');
179
+ d.textContent = t;
180
+ return d.innerHTML;
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Entorno: LOCAL (127.0.0.1 / localhost / LAN) vs REMOTO (hosting público)
185
+ // ---------------------------------------------------------------------------
186
+ function isLocalHost() {
187
+ try {
188
+ var h = location.hostname || '';
189
+ if (h === '' || h === '127.0.0.1' || h === 'localhost' || h === '::1' || h === '[::1]') return true;
190
+ return /^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(h);
191
+ } catch (e) { return false; }
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Statusbar estilo TUI
196
+ // ---------------------------------------------------------------------------
197
+ function updateStatusbar() {
198
+ if (!els.sbDot) return;
199
+ var on = bridgeOnline();
200
+ els.sbDot.className = 'dot ' + (on ? 'on' : 'off');
201
+ var local = isLocalHost();
202
+ els.sbMode.textContent = local ? 'local' : 'conectado';
203
+ els.sbMode.className = 'seg mode' + (local ? '' : ' remoto');
204
+ var s = state.currentSession;
205
+ if (s) {
206
+ els.sbModel.textContent = (s.model || 'sin modelo') + ' · ' + (s.agent || 'build');
207
+ els.sbModel.title = 'Cambiar modelo / agente de “' + (s.name || 'chat') + '”';
208
+ } else if (state.catalog && state.catalog.workspace) {
209
+ els.sbModel.textContent = shortPath(state.catalog.workspace);
210
+ els.sbModel.title = 'workspace del puente';
211
+ } else {
212
+ els.sbModel.textContent = 'sin workspace';
213
+ els.sbModel.title = '';
214
+ }
215
+ var extra = (state.sessionCount === 1 ? '1 chat' : state.sessionCount + ' chats');
216
+ var info = activeBridgeInfo();
217
+ if (info && bridgeList().length >= 2) {
218
+ extra = (info.name || info.id) + ' · ' + extra;
219
+ }
220
+ if (state.currentId !== null && state.currentSession) {
221
+ extra = '· ' + extra;
222
+ var tokTxt = sessionTokensLabel(state.currentSession);
223
+ if (tokTxt) extra += ' · ' + tokTxt + ' tok';
224
+ }
225
+ els.sbExtra.textContent = extra;
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Skins (la lista vive en themes/index.json, inyectada por chat.php)
230
+ // ---------------------------------------------------------------------------
231
+ function themeKnown(s) {
232
+ for (var i = 0; i < state.themes.length; i++) {
233
+ if (state.themes[i].slug === s) return true;
234
+ }
235
+ return false;
236
+ }
237
+
238
+ function applyTheme(slug, opts) {
239
+ var s = String(slug || 'terminal');
240
+ if (!themeKnown(s)) s = 'terminal';
241
+ state.theme = s;
242
+ els.body.setAttribute('data-theme', s);
243
+ var href = 'themes/' + s + '.css';
244
+ if (els.themeLink.getAttribute('href') !== href) {
245
+ els.themeLink.setAttribute('href', href);
246
+ }
247
+ try {
248
+ var bg = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim();
249
+ if (bg) els.themeColor.setAttribute('content', bg);
250
+ } catch (e) {}
251
+ if (!opts || opts.persist !== false) {
252
+ try {
253
+ document.cookie = 'ocx_theme=' + encodeURIComponent(s) + ';path=/;max-age=' + (60 * 60 * 24 * 365) + ';SameSite=Lax';
254
+ localStorage.setItem('ocx_theme', s);
255
+ } catch (e) {}
256
+ }
257
+ renderSkinPanel();
258
+ }
259
+
260
+ function renderSkinPanel() {
261
+ var list = state.themes;
262
+ var html = '<h4>tema</h4>';
263
+ for (var i = 0; i < list.length; i++) {
264
+ var t = list[i];
265
+ var sw = t.sw || ['#0a0d12', '#3fb950'];
266
+ var active = t.slug === state.theme ? ' active' : '';
267
+ html += '<div class="item' + active + '" data-theme="' + esc(t.slug) + '" style="--swatch-a:' + sw[0] + ';--swatch-b:' + sw[1] + '">'
268
+ + '<span class="sw"></span><span>' + esc(t.label || t.slug) + '</span></div>';
269
+ }
270
+ els.skinPanel.innerHTML = html;
271
+ }
272
+
273
+ function toggleSkinPanel() {
274
+ if (!els.skinPanel.innerHTML) renderSkinPanel();
275
+ var open = els.skinPanel.classList.contains('open');
276
+ if (open) {
277
+ els.skinPanel.classList.remove('open');
278
+ } else {
279
+ els.cmdsPanel.classList.remove('open');
280
+ renderSkinPanel();
281
+ els.skinPanel.classList.add('open');
282
+ }
283
+ }
284
+
285
+ els.btnSkin.addEventListener('click', function (e) {
286
+ e.stopPropagation();
287
+ toggleSkinPanel();
288
+ });
289
+
290
+ els.skinPanel.addEventListener('click', function (e) {
291
+ var item = e.target.closest('.item');
292
+ if (!item) return;
293
+ applyTheme(item.getAttribute('data-theme'));
294
+ });
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // Puentes (computadoras) y estado en vivo
298
+ // ---------------------------------------------------------------------------
299
+ function activeBridgeId() { return state.activeBridge || ''; }
300
+ function bridgeList() { return Array.isArray(state.bridges) ? state.bridges : []; }
301
+ function bridgeInfo(id) {
302
+ var l = bridgeList();
303
+ for (var i = 0; i < l.length; i++) if (l[i].id === id) return l[i];
304
+ return null;
305
+ }
306
+ function activeBridgeInfo() { return bridgeInfo(activeBridgeId()); }
307
+
308
+ // El catálogo/estado se guardan por puente activo (cada PC tiene el suyo).
309
+ function catalogKey() {
310
+ var id = activeBridgeId();
311
+ return id ? 'ocx_catalog_' + id : 'ocx_catalog';
312
+ }
313
+ function catalogVerKey() {
314
+ return catalogKey() + '_ver';
315
+ }
316
+ function loadStoredBridge() {
317
+ try { return localStorage.getItem('ocx_bridge') || ''; } catch (e) { return ''; }
318
+ }
319
+ function storeBridge(id) {
320
+ try { localStorage.setItem('ocx_bridge', id || ''); } catch (e) {}
321
+ }
322
+
323
+ // Sesiones visibles: solo las del puente activo cuando hay varios puentes.
324
+ // Con uno solo (o cero) se muestran todas (incluye legacy sin dueño).
325
+ function filterSessions(list) {
326
+ var n = bridgeList().length;
327
+ if (n <= 1) return list || [];
328
+ var id = activeBridgeId();
329
+ var out = [];
330
+ for (var i = 0; i < list.length; i++) {
331
+ var o = list[i].bridge || '';
332
+ if (o === id) out.push(list[i]);
333
+ }
334
+ return out;
335
+ }
336
+
337
+ // Dibuja el selector de puente en el sidebar (solo si hay más de una PC).
338
+ function renderBridgeBar() {
339
+ if (!els.bridgeBar) return;
340
+ var list = bridgeList();
341
+ if (list.length < 2) {
342
+ els.bridgeBar.classList.remove('show');
343
+ els.bridgeBar.innerHTML = '';
344
+ return;
345
+ }
346
+ var cur = activeBridgeId();
347
+ var html = '';
348
+ for (var i = 0; i < list.length; i++) {
349
+ var b = list[i];
350
+ var on = b.id === cur ? ' on' : '';
351
+ var dot = b.online ? 'bdot on' : 'bdot';
352
+ html += '<button type="button" class="bb' + on + '" data-bridge="' + esc(b.id) + '" title="'
353
+ + esc(b.name) + (b.online ? ' · en línea' : ' · apagado') + '">'
354
+ + '<span class="' + dot + '"></span>'
355
+ + '<span class="bname">' + esc(b.name || b.id || 'puente') + '</span>'
356
+ + (b.busy_session ? '<span class="bbusy" title="trabajando ahora…">●</span>' : '')
357
+ + '</button>';
358
+ }
359
+ els.bridgeBar.innerHTML = html;
360
+ els.bridgeBar.classList.add('show');
361
+ }
362
+
363
+ // Aplica la lista de puentes conocidos (sin cambiar el activo; eso lo decide
364
+ // applyBootPayload / switchActiveBridge para no mezclar catálogos).
365
+ function applyBridges(list) {
366
+ state.bridges = Array.isArray(list) ? list : [];
367
+ try { localStorage.setItem('ocx_bridges', JSON.stringify(state.bridges)); } catch (e) {}
368
+ renderBridgeBar();
369
+ }
370
+
371
+ // Cambio de puente activo (selector del usuario o al abrir un chat de otra PC).
372
+ // Se bajan catálogo + sesiones del puente elegido.
373
+ function switchActiveBridge(id) {
374
+ id = id || '';
375
+ if (id === activeBridgeId()) { renderBridgeBar(); return; }
376
+ state.activeBridge = id;
377
+ storeBridge(id);
378
+ state.catalog = null;
379
+ state.catVer = '';
380
+ if (state.view === 'chat' || state.view === 'history') goHome();
381
+ renderBridgeBar();
382
+ applyOnlineUI();
383
+ refreshBridgeData();
384
+ }
385
+
386
+ // Baja catálogo (fresco) + sesiones del puente activo.
387
+ async function refreshBridgeData() {
388
+ var data = await api('api.php?action=bootstrap');
389
+ applyBootPayload(data);
390
+ }
391
+
392
+ // Aplica la respuesta de bootstrap (o de un refresh tras cambio de puente).
393
+ function applyBootPayload(data) {
394
+ if (!data || !data.ok) return;
395
+ var newB = Array.isArray(data.bridges) ? data.bridges : null;
396
+ if (newB && newB.length) {
397
+ // El puente activo guardado puede no existir (o ser el legacy '' tras
398
+ // registrar el primer puente real): hay que pedir con el id correcto.
399
+ var cur = activeBridgeId();
400
+ var found = false;
401
+ for (var i = 0; i < newB.length; i++) {
402
+ if (newB[i].id === cur) { found = true; break; }
403
+ }
404
+ if (!found) {
405
+ state.activeBridge = newB[0].id;
406
+ storeBridge(state.activeBridge);
407
+ state.catalog = null;
408
+ state.catVer = '';
409
+ applyBridges(newB);
410
+ refreshBridgeData();
411
+ return;
412
+ }
413
+ }
414
+ if (newB) applyBridges(newB);
415
+ if (data.catalog) {
416
+ state.catalog = data.catalog;
417
+ state.catVer = data.cat_ver || '';
418
+ lsSet(catalogKey(), state.catalog);
419
+ lsSet(catalogVerKey(), state.catVer);
420
+ fillSelects();
421
+ } else if (data.cat_ver) {
422
+ state.catVer = data.cat_ver;
423
+ }
424
+ if (data.online_ts && state.catalog) {
425
+ state.catalog.last_online_ts = data.online_ts;
426
+ }
427
+ var sessions = data.sessions || [];
428
+ lsSet('ocx_sessions', sessions);
429
+ renderHome(sessions);
430
+ applyOnlineUI();
431
+ }
432
+
433
+ if (els.bridgeBar) {
434
+ els.bridgeBar.addEventListener('click', function (e) {
435
+ var b = e.target.closest('.bb');
436
+ if (!b) return;
437
+ var id = b.getAttribute('data-bridge') || '';
438
+ document.body.classList.remove('sidebar-open');
439
+ switchActiveBridge(id);
440
+ });
441
+ }
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // Online/offline (basado en el puente activo)
445
+ // ---------------------------------------------------------------------------
446
+ function bridgeOnline() {
447
+ var list = bridgeList();
448
+ if (list.length) {
449
+ var info = activeBridgeInfo();
450
+ return !!(info && info.online);
451
+ }
452
+ var t = state.catalog && state.catalog.last_online_ts;
453
+ if (!t) return true;
454
+ var age = Date.now() - new Date(t).getTime();
455
+ return isFinite(age) && age < ONLINE_MAX_AGE_MS;
456
+ }
457
+
458
+ function activeOnlineTs() {
459
+ var t = state.catalog && state.catalog.last_online_ts;
460
+ if (t) {
461
+ var age = Date.now() - new Date(t).getTime();
462
+ if (isFinite(age) && age < ONLINE_MAX_AGE_MS) return t;
463
+ }
464
+ return '';
465
+ }
466
+
467
+ function applyOnlineUI() {
468
+ var on = bridgeOnline();
469
+ state.online = on;
470
+ els.offline.style.display = on ? 'none' : '';
471
+ if (els.input) els.input.disabled = !on;
472
+ if (els.sendBtn) els.sendBtn.disabled = !on;
473
+ if (els.input) els.input.placeholder = on
474
+ ? 'escribí un mensaje…'
475
+ : 'puente apagado: no se pueden enviar mensajes';
476
+ renderBridgeBar();
477
+ if (els.bridgeDot && els.bridgeStatusText) {
478
+ els.bridgeDot.className = 'dot ' + (on ? 'on' : 'off');
479
+ var lastTs = activeOnlineTs();
480
+ var info = activeBridgeInfo();
481
+ var label = (info && info.name) ? info.name : 'puente';
482
+ if (on && lastTs) {
483
+ var d = new Date(lastTs);
484
+ els.bridgeStatusText.textContent = label + ': en línea (' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + ')';
485
+ } else if (on) {
486
+ els.bridgeStatusText.textContent = label + ': en línea';
487
+ } else if (lastTs) {
488
+ els.bridgeStatusText.textContent = label + ': apagado';
489
+ } else {
490
+ els.bridgeStatusText.textContent = label + ': desconocido';
491
+ }
492
+ }
493
+ updateStatusbar();
494
+ }
495
+
496
+ // ---------------------------------------------------------------------------
497
+ // Panel de comandos
498
+ // ---------------------------------------------------------------------------
499
+ function renderCmds() {
500
+ var html = '<h4>comandos</h4>';
501
+ for (var i = 0; i < CMDS.length; i++) {
502
+ html += '<div class="item" data-cmd="' + esc(CMDS[i].cmd) + '"><b>' + esc(CMDS[i].cmd) + '</b>'
503
+ + '<span>' + esc(CMDS[i].desc) + '</span></div>';
504
+ }
505
+ els.cmdsPanel.innerHTML = html;
506
+ }
507
+
508
+ function toggleCmds() {
509
+ if (!els.cmdsPanel.innerHTML) renderCmds();
510
+ var open = els.cmdsPanel.classList.contains('open');
511
+ if (open) {
512
+ els.cmdsPanel.classList.remove('open');
513
+ } else {
514
+ els.skinPanel.classList.remove('open');
515
+ renderCmds();
516
+ els.cmdsPanel.classList.add('open');
517
+ }
518
+ }
519
+
520
+ els.btnCmds.addEventListener('click', function (e) {
521
+ e.stopPropagation();
522
+ toggleCmds();
523
+ });
524
+
525
+ els.cmdsPanel.addEventListener('click', function (e) {
526
+ var item = e.target.closest('.item');
527
+ if (!item) return;
528
+ var val = item.dataset.cmd;
529
+ els.input.value = val + ' ';
530
+ els.input.focus();
531
+ els.cmdsPanel.classList.remove('open');
532
+ });
533
+
534
+ document.addEventListener('click', function (e) {
535
+ if (els.cmdsPanel.classList.contains('open') && !e.target.closest('#cmdsPanel') && e.target !== els.btnCmds) {
536
+ els.cmdsPanel.classList.remove('open');
537
+ }
538
+ if (els.skinPanel.classList.contains('open') && !e.target.closest('#skinPanel') && e.target !== els.btnSkin) {
539
+ els.skinPanel.classList.remove('open');
540
+ }
541
+ // El panel de procesos NO se cierra con clic afuera: es un visor de log
542
+ // que debe quedar abierto mientras mirás el chat (a diferencia de los
543
+ // menús transitorios). Solo lo cierran su botón × y el toggle ▶.
544
+ if (els.cardMenu.classList.contains('open') && !e.target.closest('#cardMenu') && !e.target.closest('.kebab')) {
545
+ els.cardMenu.classList.remove('open');
546
+ }
547
+ });
548
+
549
+ // ---------------------------------------------------------------------------
550
+ // Utilidades
551
+ // ---------------------------------------------------------------------------
552
+ function timeStr(iso) {
553
+ try {
554
+ var d = new Date(iso);
555
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + ' · ' + d.toLocaleDateString();
556
+ } catch (e) { return ''; }
557
+ }
558
+
559
+ function timeShort(iso) {
560
+ try {
561
+ var d = new Date(iso);
562
+ var now = new Date();
563
+ if (d.toDateString() === now.toDateString()) {
564
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
565
+ }
566
+ return d.toLocaleDateString([], { day: '2-digit', month: '2-digit' });
567
+ } catch (e) { return ''; }
568
+ }
569
+
570
+ function timeOnly(iso) {
571
+ try {
572
+ return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
573
+ } catch (e) { return ''; }
574
+ }
575
+
576
+ function dayKey(iso) {
577
+ var d = new Date(iso);
578
+ if (!isFinite(d.getTime())) return '';
579
+ return d.getFullYear() + '-' + d.getMonth() + '-' + d.getDate();
580
+ }
581
+
582
+ function dayLabel(iso) {
583
+ var d = new Date(iso);
584
+ if (!isFinite(d.getTime())) return '';
585
+ var now = new Date();
586
+ var start = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
587
+ var t = d.getTime();
588
+ if (t >= start) return 'hoy';
589
+ if (t >= start - 86400000) return 'ayer';
590
+ return d.toLocaleDateString();
591
+ }
592
+
593
+ function dateBucket(iso) {
594
+ var d = new Date(iso);
595
+ if (!isFinite(d.getTime())) return 'antes';
596
+ var now = new Date();
597
+ var start = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
598
+ var t = d.getTime();
599
+ if (t >= start) return 'hoy';
600
+ if (t >= start - 86400000) return 'ayer';
601
+ if (t >= start - 6 * 86400000) return 'esta semana';
602
+ if (t >= start - 29 * 86400000) return 'este mes';
603
+ return 'antes';
604
+ }
605
+
606
+ // ---------------------------------------------------------------------------
607
+ // Toast estilo TUI (reemplaza el feedback silencioso del subtítulo del header)
608
+ // ---------------------------------------------------------------------------
609
+ var toastTimer = null;
610
+ function toast(msg, kind) {
611
+ var t = document.getElementById('toast');
612
+ if (!t) {
613
+ t = document.createElement('div');
614
+ t.id = 'toast';
615
+ document.body.appendChild(t);
616
+ }
617
+ t.textContent = '❯ ' + msg;
618
+ t.className = 'show' + (kind === 'error' ? ' error' : kind === 'ok' ? ' ok' : '');
619
+ clearTimeout(toastTimer);
620
+ toastTimer = setTimeout(function () { t.className = ''; }, 2600);
621
+ }
622
+
623
+ function copyText(text, label) {
624
+ function done() { toast(label || 'copiado', 'ok'); }
625
+ function fallback() {
626
+ var ta = document.createElement('textarea');
627
+ ta.value = text;
628
+ ta.style.position = 'fixed';
629
+ ta.style.opacity = '0';
630
+ document.body.appendChild(ta);
631
+ ta.select();
632
+ try {
633
+ document.execCommand('copy');
634
+ done();
635
+ } catch (e) { toast('no se pudo copiar', 'error'); }
636
+ ta.parentNode.removeChild(ta);
637
+ }
638
+ if (navigator.clipboard && window.isSecureContext) {
639
+ navigator.clipboard.writeText(text).then(done, fallback);
640
+ } else {
641
+ fallback();
642
+ }
643
+ }
644
+
645
+ function api(path, opts) {
646
+ var settings = opts || {};
647
+ // Cada request lleva el puente activo (la PC que se está mirando).
648
+ if (typeof path === 'string' && path.indexOf('api.php') === 0 && path.indexOf('bridge=') < 0) {
649
+ path += (path.indexOf('?') >= 0 ? '&' : '?') + 'bridge=' + encodeURIComponent(activeBridgeId());
650
+ }
651
+ settings.headers = Object.assign({}, settings.headers, { 'X-Bridge-Id': activeBridgeId() });
652
+ settings.cache = 'no-store';
653
+ return fetch(path, settings)
654
+ .then(function (res) {
655
+ if (!res.ok) return { ok: false, error: 'http ' + res.status };
656
+ return res.json().catch(function () { return { ok: false, error: 'parse' }; });
657
+ })
658
+ .catch(function () { return { ok: false, error: 'network' }; });
659
+ }
660
+
661
+ function apiCsrf(method, body) {
662
+ var b = body || {};
663
+ if (!('bridge' in b)) b.bridge = activeBridgeId();
664
+ return {
665
+ method: method,
666
+ headers: { 'Content-Type': 'application/json', 'X-CSRF': CSRF, 'X-Bridge-Id': activeBridgeId() },
667
+ body: JSON.stringify(b),
668
+ };
669
+ }
670
+
671
+ function shortPath(p) {
672
+ if (!p) return 'sin carpeta';
673
+ var parts = p.replace(/\\/g, '/').split('/').filter(Boolean);
674
+ return parts.length > 2 ? '…/' + parts.slice(-2).join('/') : p;
675
+ }
676
+
677
+ function baseName(p) {
678
+ if (!p) return '';
679
+ var parts = p.replace(/\\/g, '/').split('/').filter(Boolean);
680
+ return parts.length ? parts[parts.length - 1] : p;
681
+ }
682
+
683
+ // ---------------------------------------------------------------------------
684
+ // Agrupación de sesiones por proyecto (carpeta de trabajo)
685
+ // ---------------------------------------------------------------------------
686
+ function projectLabel(folderPath) {
687
+ if (!folderPath) return 'sin proyecto';
688
+ var c = state.catalog;
689
+ if (c && Array.isArray(c.folders)) {
690
+ for (var i = 0; i < c.folders.length; i++) {
691
+ if (c.folders[i].path === folderPath) return c.folders[i].name;
692
+ }
693
+ }
694
+ return baseName(folderPath) || folderPath;
695
+ }
696
+
697
+ function loadOpenProjects() {
698
+ if (state.openProjects) return state.openProjects;
699
+ var stored = null;
700
+ try { stored = JSON.parse(localStorage.getItem('ocx_openProjects') || 'null'); } catch (e) {}
701
+ state.openProjects = (stored && typeof stored === 'object') ? stored : null;
702
+ return state.openProjects;
703
+ }
704
+
705
+ function saveOpenProjects() {
706
+ try { localStorage.setItem('ocx_openProjects', JSON.stringify(state.openProjects || {})); } catch (e) {}
707
+ }
708
+
709
+ function isProjectOpen(folderPath, sessions) {
710
+ var opens = loadOpenProjects();
711
+ if (opens && Object.prototype.hasOwnProperty.call(opens, folderPath)) {
712
+ return !!opens[folderPath];
713
+ }
714
+ // Por defecto: abierto si contiene la sesión activa o es el más reciente.
715
+ if (state.currentId !== null && sessions) {
716
+ for (var i = 0; i < sessions.length; i++) {
717
+ if (sessions[i].id === state.currentId) return true;
718
+ }
719
+ }
720
+ return false;
721
+ }
722
+
723
+ function setProjectOpen(folderPath, open) {
724
+ var opens = loadOpenProjects();
725
+ if (!opens) opens = {};
726
+ opens[folderPath] = !!open;
727
+ state.openProjects = opens;
728
+ saveOpenProjects();
729
+ }
730
+
731
+ // Agrupa la lista (ordenada por last_ts desc) en {order:[folder], groups:{folder:{label,sessions,latestTs}}}
732
+ function groupSessions(list, query) {
733
+ var q = String(query || '').trim().toLowerCase();
734
+ var groups = {};
735
+ var order = [];
736
+ for (var i = 0; i < list.length; i++) {
737
+ var s = list[i];
738
+ if (state.pendingSessions.has(s.id)) continue;
739
+ if (q) {
740
+ var hay = ((s.name || '') + ' ' + (s.preview || '') + ' ' + (s.model || '')).toLowerCase();
741
+ if (hay.indexOf(q) < 0 && projectLabel(s.folder).toLowerCase().indexOf(q) < 0) continue;
742
+ }
743
+ var key = s.folder || '';
744
+ if (!groups[key]) {
745
+ groups[key] = { folder: key, label: projectLabel(key), sessions: [], latestTs: s.last_ts || '' };
746
+ order.push(key);
747
+ }
748
+ groups[key].sessions.push(s);
749
+ }
750
+ return { order: order, groups: groups };
751
+ }
752
+
753
+ // ---------------------------------------------------------------------------
754
+ // Sidebar: árbol de proyectos y sesiones
755
+ // ---------------------------------------------------------------------------
756
+ // Tokens abreviados: 940 · 12,3k · 11,2M
757
+ function fmtTokens(n) {
758
+ n = Math.round(Number(n) || 0);
759
+ if (n >= 1000000) return (Math.round(n / 100000) / 10).toString().replace('.', ',') + 'M';
760
+ if (n >= 1000) return (Math.round(n / 100) / 10).toString().replace('.', ',') + 'k';
761
+ return String(n);
762
+ }
763
+
764
+ // Ventana de contexto del modelo segun el catalogo (0 si no se conoce).
765
+ function modelContext(model) {
766
+ var c = state.catalog || {};
767
+ var map = (c.models_ctx && typeof c.models_ctx === 'object') ? c.models_ctx : {};
768
+ var v = map[model];
769
+ return (typeof v === 'number' && v > 0) ? v : 0;
770
+ }
771
+
772
+ // Etiqueta "consumidos/capacidad" en texto plano ('' si no hay datos).
773
+ function sessionTokensLabel(s) {
774
+ var tok = Math.round(Number(s.tokens) || 0);
775
+ var ctx = modelContext(s.model);
776
+ if (!tok && !ctx) return '';
777
+ return fmtTokens(tok) + (ctx ? '/' + fmtTokens(ctx) : '');
778
+ }
779
+ function sessionTokensTitle(s) {
780
+ var tok = Math.round(Number(s.tokens) || 0);
781
+ var ctx = modelContext(s.model);
782
+ return tok + ' tokens' + (ctx ? ' de ' + ctx + ' de contexto' : '');
783
+ }
784
+
785
+ // Etiqueta "consumidos / capacidad" de la sesion.
786
+ function sessionTokensHtml(s) {
787
+ var label = sessionTokensLabel(s);
788
+ if (!label) return '';
789
+ return '<span class="stok" title="' + sessionTokensTitle(s) + '">' + label + '</span>';
790
+ }
791
+
792
+ // Subtitulo del encabezado: proyecto · modelo · agente · tokens/contexto.
793
+ function sessionSubtitle(s) {
794
+ var txt = projectLabel(s.folder) + ' · ' + (s.model || 'sin modelo') + ' · ' + (s.agent || 'build');
795
+ var label = sessionTokensLabel(s);
796
+ if (label) txt += ' · ' + label + ' tok';
797
+ return txt;
798
+ }
799
+
800
+ // Actividad agregada de un grupo de sesiones: 'working' / 'waiting' / ''.
801
+ function groupActivity(list) {
802
+ var working = false;
803
+ var waiting = false;
804
+ for (var i = 0; i < list.length; i++) {
805
+ if (list[i].state === 'working') working = true;
806
+ else if (list[i].state === 'waiting') waiting = true;
807
+ }
808
+ return working ? 'working' : (waiting ? 'waiting' : '');
809
+ }
810
+
811
+ function sessionItemHtml(s, withFolder) {
812
+ var active = s.id === state.currentId ? ' active' : '';
813
+ var pen = s.state === 'working' ? '<span class="sbusy" title="trabajando ahora…"></span>'
814
+ : (s.state === 'waiting' ? '<span class="spen" title="esperando respuesta"></span>' : '');
815
+ var nameHtml = state.sideQuery ? highlightText(s.name || '', state.sideQuery) : esc(s.name);
816
+ return '<button type="button" class="sitem' + active + '" data-sid="' + s.id + '">'
817
+ + '<span class="sname">' + nameHtml + '</span>'
818
+ + (withFolder ? '<span class="sfolder">' + esc(projectLabel(s.folder)) + '</span>' : '')
819
+ + pen
820
+ + sessionTokensHtml(s)
821
+ + '<span class="stime">' + esc(timeShort(s.last_ts)) + '</span>'
822
+ + '</button>';
823
+ }
824
+
825
+ function totalCount(g) {
826
+ var t = 0;
827
+ for (var k = 0; k < g.order.length; k++) t += g.groups[g.order[k]].sessions.length;
828
+ return t;
829
+ }
830
+
831
+ // Huella de la lista: si nada cambió (poll cada pocos segundos) no se
832
+ // reconstruye el árbol y el scroll del sidebar queda intacto.
833
+ function sideSig() {
834
+ var p = [state.sideQuery, state.focusFolder, state.currentId, state.catVer, state.pendingSessions.size, state.sessions.length];
835
+ for (var i = 0; i < state.sessions.length; i++) {
836
+ var s = state.sessions[i];
837
+ p.push(s.id + ':' + (s.state || '') + ':' + (s.preview_role || '') + ':' + (s.tokens || 0) + ':' + (s.last_ts || '') + ':' + (s.name || ''));
838
+ }
839
+ return p.join('|');
840
+ }
841
+
842
+ function renderSidebar() {
843
+ if (!els.sessionTree) return;
844
+ var sig = sideSig();
845
+ if (sig === state.sideSig) return;
846
+ state.sideSig = sig;
847
+ var q = String(state.sideQuery || '').trim().toLowerCase();
848
+ // Lista SOLO de proyectos, ordenados por actividad reciente (última sesión).
849
+ var projects = [];
850
+ var byKey = {};
851
+ for (var i = 0; i < state.sessions.length; i++) {
852
+ var s = state.sessions[i];
853
+ if (state.pendingSessions.has(s.id)) continue;
854
+ var key = s.folder || '';
855
+ var label = projectLabel(key) || '(sin proyecto)';
856
+ if (q && label.toLowerCase().indexOf(q) < 0) continue;
857
+ if (!byKey[key]) {
858
+ byKey[key] = { folder: key, label: label, sessions: [], latestTs: s.last_ts || '' };
859
+ projects.push(byKey[key]);
860
+ }
861
+ var grp = byKey[key];
862
+ grp.sessions.push(s);
863
+ if ((s.last_ts || '') > grp.latestTs) grp.latestTs = s.last_ts;
864
+ }
865
+ projects.sort(function (a, b) {
866
+ return String(b.latestTs).localeCompare(String(a.latestTs));
867
+ });
868
+
869
+ var html = '';
870
+ if (!projects.length) {
871
+ html = '<div class="tree-empty">' + (q ? 'sin proyectos para “' + esc(state.sideQuery) + '”' : 'no hay proyectos todavía') + '</div>';
872
+ } else {
873
+ for (var p = 0; p < projects.length; p++) {
874
+ var pr = projects[p];
875
+ var act = groupActivity(pr.sessions);
876
+ var actHtml = act === 'working' ? '<span class="sbusy" title="trabajando…"></span>'
877
+ : (act === 'waiting' ? '<span class="sdot" title="esperando respuesta"></span>' : '');
878
+ var active = pr.folder === state.focusFolder ? ' active' : '';
879
+ var showAdd = pr.folder !== '';
880
+ html += '<div class="prow' + active + '" data-folder="' + esc(pr.folder) + '">'
881
+ + '<button type="button" class="proj-head" data-folder="' + esc(pr.folder) + '" title="Ver chats de ' + esc(pr.label) + '">'
882
+ + '<span class="chev">▸</span>'
883
+ + '<span class="pname">' + esc(pr.label) + '</span>'
884
+ + actHtml
885
+ + '<span class="pcount">' + pr.sessions.length + '</span>'
886
+ + '</button>'
887
+ + (showAdd ? '<button type="button" class="padd" data-add="' + esc(pr.folder) + '" title="nueva sesión en ' + esc(pr.label) + '">+</button>' : '')
888
+ + '</div>';
889
+ }
890
+ }
891
+
892
+ var stv = els.sessionTree.scrollTop;
893
+ els.sessionTree.innerHTML = html;
894
+ els.sessionTree.scrollTop = stv;
895
+ }
896
+
897
+ // Un solo listener con delegación: los proyectos se re-renderizan sin re-bindear.
898
+ els.sessionTree.addEventListener('click', function (e) {
899
+ var add = e.target.closest('.padd');
900
+ if (add) {
901
+ e.stopPropagation();
902
+ document.body.classList.remove('sidebar-open');
903
+ openModal(add.getAttribute('data-add') || '');
904
+ return;
905
+ }
906
+ var go = e.target.closest('.proj-head[data-folder]');
907
+ if (go) {
908
+ e.stopPropagation();
909
+ document.body.classList.remove('sidebar-open');
910
+ focusProject(go.getAttribute('data-folder'));
911
+ return;
912
+ }
913
+ });
914
+
915
+ if (els.sideSearch) {
916
+ var sideSearchDebounce = null;
917
+ els.sideSearch.addEventListener('input', function () {
918
+ clearTimeout(sideSearchDebounce);
919
+ var v = els.sideSearch.value;
920
+ sideSearchDebounce = setTimeout(function () {
921
+ state.sideQuery = v;
922
+ renderSidebar();
923
+ }, 160);
924
+ });
925
+ }
926
+
927
+ // ---------------------------------------------------------------------------
928
+ // Router de vistas
929
+ // ---------------------------------------------------------------------------
930
+ function showView(name) {
931
+ state.view = name;
932
+ document.body.classList.remove('sidebar-open');
933
+ if (els.composeOpen) els.composeOpen.classList.toggle('show', name === 'chat');
934
+ if (name !== 'chat') composeCloseSheet();
935
+ els.home.style.display = 'none';
936
+ els.chat.style.display = 'none';
937
+ els.viewFiles.style.display = 'none';
938
+ els.viewSessions.style.display = 'none';
939
+ els.viewSearch.style.display = 'none';
940
+ els.viewHistory.style.display = 'none';
941
+ els.viewPreview.style.display = 'none';
942
+ els.viewProcs.style.display = 'none';
943
+
944
+ if (name !== 'procs') stopProcView();
945
+
946
+ if (name === 'home') {
947
+ els.home.style.display = '';
948
+ els.btnBack.style.display = 'none';
949
+ els.sendForm.style.display = 'none';
950
+ els.btnCmds.style.display = 'none';
951
+ els.hTitle.textContent = 'OpenBridge';
952
+ els.hSub.textContent = state.subtitle || 'cargando…';
953
+ try { history.replaceState(null, '', 'chat.php'); } catch (e) {}
954
+ } else if (name === 'chat') {
955
+ els.chat.style.display = 'flex';
956
+ els.btnBack.style.display = '';
957
+ els.sendForm.style.display = '';
958
+ els.btnCmds.style.display = '';
959
+ autoGrow();
960
+ } else if (name === 'files') {
961
+ els.viewFiles.style.display = '';
962
+ els.btnBack.style.display = 'none';
963
+ els.sendForm.style.display = 'none';
964
+ els.btnCmds.style.display = 'none';
965
+ els.hTitle.textContent = 'archivos';
966
+ els.hSub.textContent = 'workspace del puente';
967
+ loadFiles(state.filesPath);
968
+ } else if (name === 'sessions') {
969
+ els.viewSessions.style.display = '';
970
+ els.btnBack.style.display = 'none';
971
+ els.sendForm.style.display = 'none';
972
+ els.btnCmds.style.display = 'none';
973
+ els.hTitle.textContent = 'sesiones opencode';
974
+ els.hSub.textContent = 'vinculadas a tus chats';
975
+ renderSessionsView();
976
+ } else if (name === 'search') {
977
+ els.viewSearch.style.display = '';
978
+ els.btnBack.style.display = 'none';
979
+ els.sendForm.style.display = 'none';
980
+ els.btnCmds.style.display = 'none';
981
+ els.hTitle.textContent = 'buscar';
982
+ els.hSub.textContent = 'en todos tus chats';
983
+ renderSearchView();
984
+ } else if (name === 'history') {
985
+ els.viewHistory.style.display = '';
986
+ els.btnBack.style.display = '';
987
+ els.sendForm.style.display = 'none';
988
+ els.btnCmds.style.display = 'none';
989
+ els.hTitle.textContent = 'historial';
990
+ els.hSub.textContent = state.histLabel || 'de un proyecto';
991
+ renderHistory();
992
+ } else if (name === 'preview') {
993
+ els.viewPreview.style.display = '';
994
+ els.btnBack.style.display = 'none';
995
+ els.sendForm.style.display = 'none';
996
+ els.btnCmds.style.display = 'none';
997
+ els.hTitle.textContent = 'vista previa';
998
+ els.hSub.textContent = 'túneles del proyecto';
999
+ renderPreviewView();
1000
+ } else if (name === 'procs') {
1001
+ els.viewProcs.style.display = '';
1002
+ els.btnBack.style.display = 'none';
1003
+ els.sendForm.style.display = 'none';
1004
+ els.btnCmds.style.display = 'none';
1005
+ els.hTitle.textContent = 'procesos';
1006
+ var pf = sessionProcFolder();
1007
+ els.hSub.textContent = pf ? projectLabel(pf) : 'de la sesión abierta';
1008
+ enterProcsView();
1009
+ }
1010
+
1011
+ if (els.fabNew) els.fabNew.classList.toggle('show', name === 'home');
1012
+
1013
+ for (var i = 0; i < els.sideButtons.length; i++) {
1014
+ var b = els.sideButtons[i];
1015
+ var v = b.getAttribute('data-view');
1016
+ b.classList.toggle('active', v === name);
1017
+ }
1018
+ updateStatusbar();
1019
+ }
1020
+
1021
+ for (var sb = 0; sb < els.sideButtons.length; sb++) {
1022
+ (function (btn) {
1023
+ btn.addEventListener('click', function () {
1024
+ var v = btn.getAttribute('data-view');
1025
+ if (!v) return;
1026
+ if (v === 'home') goHome();
1027
+ else if (v === 'chat' && state.currentId) showView('chat');
1028
+ else showView(v);
1029
+ document.body.classList.remove('sidebar-open');
1030
+ });
1031
+ })(els.sideButtons[sb]);
1032
+ }
1033
+
1034
+ els.btnBurger.addEventListener('click', function () {
1035
+ document.body.classList.toggle('sidebar-open');
1036
+ });
1037
+ els.scrim.addEventListener('click', function () {
1038
+ composeCloseSheet();
1039
+ document.body.classList.remove('sidebar-open');
1040
+ });
1041
+
1042
+ // ---------------------------------------------------------------------------
1043
+ // Hoja de escritura (móvil): la barra de envío emerge desde abajo como modal.
1044
+ // En escritorio todo queda como siempre (la hoja solo existe < 1024px).
1045
+ // ---------------------------------------------------------------------------
1046
+ function isMobileView() {
1047
+ return window.matchMedia('(max-width: 1023px)').matches;
1048
+ }
1049
+ function composeOpenSheet() {
1050
+ if (!els.sendForm || state.view !== 'chat') return;
1051
+ if (els.imgPreview && els.imgPreview.parentNode !== els.sendForm) {
1052
+ els.sendForm.insertBefore(els.imgPreview, els.slashPanel);
1053
+ }
1054
+ els.sendForm.classList.add('open');
1055
+ document.body.classList.add('compose-open');
1056
+ setTimeout(function () { try { els.input.focus(); } catch (e) {} }, 180);
1057
+ }
1058
+ function composeCloseSheet() {
1059
+ if (!els.sendForm) return;
1060
+ els.sendForm.classList.remove('open');
1061
+ document.body.classList.remove('compose-open');
1062
+ if (els.imgPreview && els.imgPreview.parentNode === els.sendForm && els.composeOpen) {
1063
+ els.chat.insertBefore(els.imgPreview, els.composeOpen);
1064
+ }
1065
+ }
1066
+ if (els.composeOpen) els.composeOpen.addEventListener('click', composeOpenSheet);
1067
+ var composeCloseEl = els.sendForm.querySelector('.shead');
1068
+ if (composeCloseEl) composeCloseEl.addEventListener('click', composeCloseSheet);
1069
+
1070
+ // ---------------------------------------------------------------------------
1071
+ // Sidebar: logo → inicio, ajuste de ancho y ocultar (desktop) / cerrar drawer (móvil)
1072
+ // ---------------------------------------------------------------------------
1073
+ function sbHidden() {
1074
+ try { return localStorage.getItem('ocx_sbHidden') === '1'; } catch (e) { return false; }
1075
+ }
1076
+ function setSbHidden(hidden) {
1077
+ try { localStorage.setItem('ocx_sbHidden', hidden ? '1' : '0'); } catch (e) {}
1078
+ document.body.classList.toggle('sb-hide', hidden);
1079
+ }
1080
+ function sbWidth() {
1081
+ try {
1082
+ var w = parseInt(localStorage.getItem('ocx_sbW'), 10);
1083
+ if (w >= 200 && w <= 480) return w;
1084
+ } catch (e) {}
1085
+ return 264;
1086
+ }
1087
+ function setSbWidth(w) {
1088
+ try { localStorage.setItem('ocx_sbW', String(w)); } catch (e) {}
1089
+ document.documentElement.style.setProperty('--sb-w', w + 'px');
1090
+ }
1091
+
1092
+ if (els.brandHome) {
1093
+ els.brandHome.addEventListener('click', function (e) {
1094
+ if (e.target.closest('.sbhide')) return;
1095
+ document.body.classList.remove('sidebar-open');
1096
+ goHome();
1097
+ });
1098
+ }
1099
+ if (els.btnSbHide) {
1100
+ els.btnSbHide.addEventListener('click', function (e) {
1101
+ e.stopPropagation();
1102
+ if (window.innerWidth < 1024) {
1103
+ // En móvil el botón « cierra el drawer (se reabre con la burger).
1104
+ document.body.classList.remove('sidebar-open');
1105
+ return;
1106
+ }
1107
+ setSbHidden(true);
1108
+ });
1109
+ }
1110
+ if (els.sbReveal) {
1111
+ els.sbReveal.addEventListener('click', function () {
1112
+ setSbHidden(false);
1113
+ });
1114
+ }
1115
+ var sbResizer = document.getElementById('sbResizer');
1116
+ if (sbResizer) {
1117
+ sbResizer.addEventListener('pointerdown', function (e) {
1118
+ if (window.innerWidth < 1024) return;
1119
+ e.preventDefault();
1120
+ sbResizer.setPointerCapture(e.pointerId);
1121
+ sbResizer.classList.add('dragging');
1122
+ document.body.style.userSelect = 'none';
1123
+ var move = function (ev) {
1124
+ setSbWidth(Math.max(200, Math.min(480, ev.clientX)));
1125
+ };
1126
+ var up = function () {
1127
+ sbResizer.classList.remove('dragging');
1128
+ document.body.style.userSelect = '';
1129
+ sbResizer.removeEventListener('pointermove', move);
1130
+ sbResizer.removeEventListener('pointerup', up);
1131
+ };
1132
+ sbResizer.addEventListener('pointermove', move);
1133
+ sbResizer.addEventListener('pointerup', up);
1134
+ });
1135
+ sbResizer.addEventListener('dblclick', function () { setSbWidth(264); });
1136
+ }
1137
+ if (window.innerWidth >= 1024) {
1138
+ document.body.classList.toggle('sb-hide', sbHidden());
1139
+ document.documentElement.style.setProperty('--sb-w', sbWidth() + 'px');
1140
+ }
1141
+
1142
+ // ---------------------------------------------------------------------------
1143
+ // Home: sesiones agrupadas por proyecto
1144
+ // ---------------------------------------------------------------------------
1145
+ function renderHome(sessions) {
1146
+ // Con varios puentes, solo se muestran las sesiones del puente activo.
1147
+ var list = filterSessions(sessions || state.sessions || []);
1148
+ state.sessions = list;
1149
+ state.sessionCount = list.length;
1150
+ var on = bridgeOnline();
1151
+ var lastTs = activeOnlineTs();
1152
+ var whoInfo = activeBridgeInfo();
1153
+ var whoLabel = (whoInfo && bridgeList().length >= 2) ? (whoInfo.name || whoInfo.id) : 'puente';
1154
+ var statusHtml;
1155
+ if (on && lastTs) {
1156
+ statusHtml = '<span class="bridge-status"><span class="dot"></span>' + esc(whoLabel) + ' en línea</span>';
1157
+ } else if (lastTs) {
1158
+ statusHtml = '<span class="bridge-status off"><span class="dot"></span>' + esc(whoLabel) + ' apagado</span>';
1159
+ } else {
1160
+ statusHtml = '<span class="bridge-status off"><span class="dot"></span>esperando puente…</span>';
1161
+ }
1162
+
1163
+ var html = '<div class="hero">'
1164
+ + '<h1>sesiones</h1>'
1165
+ + '<p>' + statusHtml + ' · ' + (list.length === 1 ? '1 chat' : list.length + ' chats') + '</p>'
1166
+ + '</div>'
1167
+ + '<button class="newbtn">+ nueva sesión</button>';
1168
+
1169
+ var g = groupSessions(list, '');
1170
+ if (!g.order.length) {
1171
+ html += '<div class="empty">No hay chats todavía. Creá uno para empezar.</div>';
1172
+ } else {
1173
+ var opens = loadOpenProjects();
1174
+ var flash = state.focusFlash; // se consume una sola vez
1175
+ state.focusFlash = null;
1176
+ var firstKey = g.order[0];
1177
+ for (var i = 0; i < g.order.length; i++) {
1178
+ var key = g.order[i];
1179
+ var grp = g.groups[key];
1180
+ var label = grp.label || '(sin proyecto)';
1181
+ var hasStored = !!(opens && Object.prototype.hasOwnProperty.call(opens, key));
1182
+ var containsCur = false;
1183
+ for (var cc = 0; cc < grp.sessions.length; cc++) {
1184
+ if (grp.sessions[cc].id === state.currentId) { containsCur = true; break; }
1185
+ }
1186
+ var open = hasStored ? !!opens[key]
1187
+ : (containsCur || key === firstKey || key === flash || key === state.focusFolder);
1188
+ var act = groupActivity(grp.sessions);
1189
+ var actHtml = act === 'working' ? '<span class="sbusy" title="trabajando ahora…"></span> '
1190
+ : (act === 'waiting' ? '<span class="sdot" title="esperando respuesta"></span> ' : '');
1191
+ var total = grp.sessions.length;
1192
+ var shown = Math.min(total, 4);
1193
+ var hidden = total - shown;
1194
+ html += '<div class="projsec' + (open ? ' open' : '') + '" data-folder="' + esc(key) + '">'
1195
+ + '<button type="button" class="pshead" data-folder="' + esc(key) + '" title="' + (open ? 'Contraer' : 'Expandir') + '">'
1196
+ + '<span class="chev">' + (open ? '▾' : '▸') + '</span>'
1197
+ + '<span class="psname">' + esc(label) + '</span>'
1198
+ + actHtml
1199
+ + '<span class="pscount">' + total + (total === 1 ? ' sesión' : ' sesiones') + '</span>'
1200
+ + '</button>'
1201
+ + '<div class="pbody">'
1202
+ + '<div class="cards">';
1203
+ for (var j = 0; j < shown; j++) {
1204
+ var s = grp.sessions[j];
1205
+ var prev = s.preview ? esc(s.preview) : 'Sin mensajes aún';
1206
+ var busy = s.state === 'working'
1207
+ ? '<span class="sbusy" title="trabajando ahora…"></span> '
1208
+ : (s.state === 'waiting' ? '<span style="color:var(--mine)">●</span> ' : '');
1209
+ html += '<div class="card" data-sid="' + s.id + '">'
1210
+ + '<div class="grow">'
1211
+ + '<div class="cname">' + esc(s.name) + '</div>'
1212
+ + '<div class="cmeta">' + busy + esc(s.model || 'sin modelo') + ' · ' + esc(s.agent || 'build') + '</div>'
1213
+ + (s.preview ? '<div class="cpreview">' + prev + '</div>' : '')
1214
+ + '</div>'
1215
+ + '<button type="button" class="kebab" aria-label="Opciones" title="Opciones">⋮</button>'
1216
+ + '</div>';
1217
+ }
1218
+ html += '</div>';
1219
+ html += '<button type="button" class="histbtn" data-folder="' + esc(key) + '">'
1220
+ + (hidden > 0 ? 'ver historial · ' + hidden + ' más' : 'ver historial')
1221
+ + '</button>';
1222
+ html += '</div></div>';
1223
+ }
1224
+ }
1225
+ els.home.innerHTML = html;
1226
+
1227
+ // Resaltar y llevar al proyecto pedido desde el sidebar (una sola vez).
1228
+ if (flash) {
1229
+ var secs = els.home.querySelectorAll('.projsec');
1230
+ for (var s2 = 0; s2 < secs.length; s2++) {
1231
+ if (secs[s2].getAttribute('data-folder') === flash) {
1232
+ secs[s2].classList.add('flash');
1233
+ try { secs[s2].scrollIntoView({ block: 'start', behavior: 'smooth' }); } catch (e) {}
1234
+ setTimeout(function (el) {
1235
+ if (el) el.classList.remove('flash');
1236
+ }, 1600, secs[s2]);
1237
+ break;
1238
+ }
1239
+ }
1240
+ }
1241
+
1242
+ var cards = els.home.querySelectorAll('.card');
1243
+ for (var a = 0; a < cards.length; a++) {
1244
+ (function (card) {
1245
+ card.addEventListener('click', function (e) {
1246
+ if (e.target.closest('.kebab')) return;
1247
+ openChat(parseInt(card.dataset.sid, 10));
1248
+ });
1249
+ })(cards[a]);
1250
+ }
1251
+
1252
+ renderSidebar();
1253
+ state.subtitle = list.length === 1 ? '1 chat' : list.length + ' chats';
1254
+ if (state.view === 'home') els.hSub.textContent = state.subtitle;
1255
+ updateStatusbar();
1256
+ }
1257
+
1258
+ async function loadSessions() {
1259
+ var data = await api('api.php?action=sessions');
1260
+ if (data.ok) {
1261
+ lsSet('ocx_sessions', data.sessions || []);
1262
+ renderHome(data.sessions || []);
1263
+ } else if (!state.sessions.length) {
1264
+ els.home.innerHTML = '<div class="empty">Sin conexión</div>';
1265
+ }
1266
+ }
1267
+
1268
+ // ---------------------------------------------------------------------------
1269
+ // Render de contenido: markdown ligero (negritas, cursiva, tachado, código,
1270
+ // listas, encabezados, citas, separadores, enlaces) + fences de código.
1271
+ // ---------------------------------------------------------------------------
1272
+ // Inline: code spans primero; luego bold/italic/strike/enlaces sobre lo demás.
1273
+ function mdInline(raw) {
1274
+ var out = '';
1275
+ var parts = String(raw).split(/`([^`\n]+)`/g); // impares = código
1276
+ for (var i = 0; i < parts.length; i++) {
1277
+ if (i % 2 === 1) {
1278
+ out += '<code class="inl">' + esc(parts[i]) + '</code>';
1279
+ continue;
1280
+ }
1281
+ var s = esc(parts[i]);
1282
+ s = s.replace(/\*\*([^*\n][^*\n]*?)\*\*/g, '<strong>$1</strong>');
1283
+ s = s.replace(/(^|[\s(>.,;:!?¡¿"“])\*([^*\s][^*\n]*?)\*(?=$|[\s).,;:!?<>"”])/g, '$1<em>$2</em>');
1284
+ s = s.replace(/(^|[\s(>.,;:!?¡¿"“])_([^_\s][^_\n]*?)_(?=$|[\s).,;:!?<>"”])/g, '$1<em>$2</em>');
1285
+ s = s.replace(/~~([^~\n]+)~~/g, '<del>$1</del>');
1286
+ s = s.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)<>"']+)\)/g, function (m, t, u) {
1287
+ return '<a href="' + u + '" target="_blank" rel="noopener noreferrer">' + t + '</a>';
1288
+ });
1289
+ out += s;
1290
+ }
1291
+ return out;
1292
+ }
1293
+
1294
+ // Bloques: encabezados, separadores, citas, listas y párrafos (línea = <br>,
1295
+ // para respetar el corte de línea del output del CLI).
1296
+ function mdBlocks(raw) {
1297
+ var lines = String(raw).replace(/\r\n?/g, '\n').split('\n');
1298
+ var html = '';
1299
+ var para = [];
1300
+ var list = null;
1301
+ function flushPara() {
1302
+ if (!para.length) return;
1303
+ html += '<div class="md-p">' + para.map(mdInline).join('<br>') + '</div>';
1304
+ para = [];
1305
+ }
1306
+ function flushList() {
1307
+ if (!list) return;
1308
+ html += '</' + list + '>';
1309
+ list = null;
1310
+ }
1311
+ for (var i = 0; i < lines.length; i++) {
1312
+ var line = lines[i];
1313
+ var t = line.trim();
1314
+ if (t === '') { flushPara(); flushList(); continue; }
1315
+ var h = t.match(/^(#{1,4})\s+(.*)$/);
1316
+ if (h) { flushPara(); flushList(); html += '<div class="md-h md-h' + h[1].length + '">' + mdInline(h[2]) + '</div>'; continue; }
1317
+ if (/^([-*_])\1{2,}$/.test(t)) { flushPara(); flushList(); html += '<hr class="md-hr">'; continue; }
1318
+ var q = line.match(/^\s*>\s?(.*)$/);
1319
+ if (q) { flushPara(); flushList(); html += '<div class="md-quote">' + mdInline(q[1]) + '</div>'; continue; }
1320
+ var ul = line.match(/^\s*[-*+]\s+(.*)$/);
1321
+ if (ul) {
1322
+ flushPara();
1323
+ if (list !== 'ul') { flushList(); html += '<ul class="md-ul">'; list = 'ul'; }
1324
+ html += '<li>' + mdInline(ul[1]) + '</li>';
1325
+ continue;
1326
+ }
1327
+ var ol = line.match(/^\s*\d+[.)]\s+(.*)$/);
1328
+ if (ol) {
1329
+ flushPara();
1330
+ if (list !== 'ol') { flushList(); html += '<ol class="md-ol">'; list = 'ol'; }
1331
+ html += '<li>' + mdInline(ol[1]) + '</li>';
1332
+ continue;
1333
+ }
1334
+ flushList();
1335
+ para.push(line);
1336
+ }
1337
+ flushPara();
1338
+ flushList();
1339
+ return html;
1340
+ }
1341
+
1342
+ // Resalta la búsqueda sobre nodos de texto (DOM), sin tocar las etiquetas.
1343
+ function highlightDom(html, query) {
1344
+ var q = String(query || '').trim().toLowerCase();
1345
+ if (!q) return html;
1346
+ var tmp = document.createElement('div');
1347
+ tmp.innerHTML = html;
1348
+ var walker = document.createTreeWalker(tmp, NodeFilter.SHOW_TEXT, null);
1349
+ var nodes = [];
1350
+ while (walker.nextNode()) nodes.push(walker.currentNode);
1351
+ for (var i = 0; i < nodes.length; i++) {
1352
+ var node = nodes[i];
1353
+ var text = node.nodeValue;
1354
+ var low = text.toLowerCase();
1355
+ if (low.indexOf(q) < 0) continue;
1356
+ var frag = document.createDocumentFragment();
1357
+ var pos = 0;
1358
+ var idx = low.indexOf(q);
1359
+ while (idx >= 0) {
1360
+ if (idx > pos) frag.appendChild(document.createTextNode(text.slice(pos, idx)));
1361
+ var mark = document.createElement('mark');
1362
+ mark.textContent = text.substr(idx, q.length);
1363
+ frag.appendChild(mark);
1364
+ pos = idx + q.length;
1365
+ idx = low.indexOf(q, pos);
1366
+ }
1367
+ if (pos < text.length) frag.appendChild(document.createTextNode(text.slice(pos)));
1368
+ node.parentNode.replaceChild(frag, node);
1369
+ }
1370
+ return tmp.innerHTML;
1371
+ }
1372
+
1373
+ // ---------------------------------------------------------------------------
1374
+ // Resaltado de sintaxis ligero (sin dependencias): reglas sticky por familia
1375
+ // de lenguajes, aplicadas en un solo pase. Colores vía tokens --hl-* del tema.
1376
+ // ---------------------------------------------------------------------------
1377
+ var HL_RULES = {
1378
+ js: [
1379
+ { re: /\/\*[\s\S]*?(?:\*\/|$)/y, cls: 'cm' },
1380
+ { re: /\/\/[^\n]*/y, cls: 'cm' },
1381
+ { re: /"(?:\\.|[^"\\\n])*"?/y, cls: 'st' },
1382
+ { re: /'(?:\\.|[^'\\\n])*'?/y, cls: 'st' },
1383
+ { re: /`(?:\\.|[^`\\])*`?/y, cls: 'st' },
1384
+ { re: /\b0x[0-9a-fA-F]+\b|\b\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?\b/y, cls: 'nu' },
1385
+ { re: /\b(?:if|else|for|while|do|switch|case|default|break|continue|return|try|catch|finally|throw|await|yield|new|delete|typeof|instanceof|in|of|void)\b/y, cls: 'k2' },
1386
+ { re: /\b(?:var|let|const|function|class|extends|super|this|static|get|set|async|import|export|from|as|true|false|null|undefined|NaN)\b/y, cls: 'kw' },
1387
+ { re: /\b[A-Za-z_$][\w$]*(?=\s*\()/y, cls: 'fn' },
1388
+ { re: /\b[A-Z][\w$]*\b/y, cls: 'ty' }
1389
+ ],
1390
+ py: [
1391
+ { re: /"""[\s\S]*?(?:"""|$)|'''[\s\S]*?(?:'''|$)/y, cls: 'st' },
1392
+ { re: /#[^\n]*/y, cls: 'cm' },
1393
+ { re: /"(?:\\.|[^"\\\n])*"?/y, cls: 'st' },
1394
+ { re: /'(?:\\.|[^'\\\n])*'?/y, cls: 'st' },
1395
+ { re: /\b0x[0-9a-fA-F]+\b|\b\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?\b/y, cls: 'nu' },
1396
+ { re: /\b(?:if|elif|else|for|while|return|break|continue|pass|raise|try|except|finally|with|as|in|is|not|and|or|assert|del|yield|await|async|match|case)\b/y, cls: 'k2' },
1397
+ { re: /\b(?:def|class|lambda|import|from|global|nonlocal|True|False|None)\b/y, cls: 'kw' },
1398
+ { re: /\bself\b|\bcls\b/y, cls: 'pr' },
1399
+ { re: /\b[A-Za-z_]\w*(?=\s*\()/y, cls: 'fn' },
1400
+ { re: /\b[A-Z]\w*\b/y, cls: 'ty' }
1401
+ ],
1402
+ clike: [
1403
+ { re: /\/\*[\s\S]*?(?:\*\/|$)/y, cls: 'cm' },
1404
+ { re: /\/\/[^\n]*/y, cls: 'cm' },
1405
+ { re: /"(?:\\.|[^"\\\n])*"?/y, cls: 'st' },
1406
+ { re: /'(?:\\.|[^'\\\n])*'?/y, cls: 'st' },
1407
+ { re: /\b0x[0-9a-fA-F]+\b|\b\d[\d_]*(?:\.\d+)?(?:[uUlLfF]+)?\b/y, cls: 'nu' },
1408
+ { re: /\b(?:if|else|for|while|do|switch|case|default|break|continue|return|goto|try|catch|throw|new|delete|defer|go|range|select|fallthrough|match)\b/y, cls: 'k2' },
1409
+ { re: /\b(?:function|func|fn|class|struct|interface|enum|union|type|typedef|const|static|final|abstract|public|private|protected|virtual|override|namespace|using|template|typename|operator|sizeof|package|import|use|mod|pub|impl|trait|var|let|mut|void|int|char|float|double|long|short|bool|byte|uint|string|true|false|null|nullptr|NULL|nil|this|self)\b/y, cls: 'kw' },
1410
+ { re: /\b[A-Za-z_]\w*(?=\s*\()/y, cls: 'fn' },
1411
+ { re: /\b[A-Z]\w*\b/y, cls: 'ty' }
1412
+ ],
1413
+ sh: [
1414
+ { re: /#[^\n]*/y, cls: 'cm' },
1415
+ { re: /"(?:\\.|[^"\\])*"?/y, cls: 'st' },
1416
+ { re: /'[^']*'?/y, cls: 'st' },
1417
+ { re: /\$\{[^}\n]*\}|\$[\w]+/y, cls: 'pr' },
1418
+ { re: /\b\d+\b/y, cls: 'nu' },
1419
+ { re: /\b(?:if|then|else|elif|fi|for|while|until|do|done|case|esac|in|function|return|export|local|declare|readonly|exit)\b/y, cls: 'kw' },
1420
+ { re: /\b[A-Za-z_][\w-]*(?=\()/y, cls: 'fn' }
1421
+ ],
1422
+ php: [
1423
+ { re: /\/\*[\s\S]*?(?:\*\/|$)|\/\/[^\n]*|#[^\n]*/y, cls: 'cm' },
1424
+ { re: /<\?(?:php|=)|\?>/y, cls: 'kw' },
1425
+ { re: /"(?:\\.|[^"\\])*"?/y, cls: 'st' },
1426
+ { re: /'(?:\\.|[^'\\])*'?/y, cls: 'st' },
1427
+ { re: /\$[\w]+/y, cls: 'pr' },
1428
+ { re: /\b0x[0-9a-fA-F]+\b|\b\d[\d_]*(?:\.\d+)?\b/y, cls: 'nu' },
1429
+ { re: /\b(?:if|else|elseif|for|foreach|while|do|switch|case|default|break|continue|return|try|catch|finally|throw|as|new|echo|print|yield|instanceof)\b/y, cls: 'k2' },
1430
+ { re: /\b(?:function|class|extends|implements|interface|trait|namespace|use|public|private|protected|static|final|abstract|const|var|global|true|false|null|self|parent|this)\b/y, cls: 'kw' },
1431
+ { re: /\b[A-Za-z_]\w*(?=\s*\()/y, cls: 'fn' },
1432
+ { re: /\b[A-Z]\w*\b/y, cls: 'ty' }
1433
+ ],
1434
+ html: [
1435
+ { re: /<!--[\s\S]*?(?:-->|$)/y, cls: 'cm' },
1436
+ { re: /"(?:[^"\\\n]|\\.)*"?|'(?:[^'\\\n]|\\.)*'?/y, cls: 'st' },
1437
+ { re: /<\/?[a-zA-Z][\w-]*|\/?>|<!DOCTYPE(?=[\s>])/iy, cls: 'kw' },
1438
+ { re: /[a-zA-Z-]+(?=\s*=)/y, cls: 'pr' }
1439
+ ],
1440
+ css: [
1441
+ { re: /\/\*[\s\S]*?(?:\*\/|$)/y, cls: 'cm' },
1442
+ { re: /"(?:\\.|[^"\\\n])*"?|'(?:\\.|[^'\\\n])*'?/y, cls: 'st' },
1443
+ { re: /@[\w-]+/y, cls: 'kw' },
1444
+ { re: /#[0-9a-fA-F]{3,8}\b/y, cls: 'nu' },
1445
+ { re: /\b\d+(?:\.\d+)?(?:px|em|rem|vh|vw|vmin|vmax|%|s|ms|deg|fr|ch|ex)?\b/y, cls: 'nu' },
1446
+ { re: /--[\w-]+|[a-zA-Z-]+(?=\s*:)/y, cls: 'pr' }
1447
+ ],
1448
+ json: [
1449
+ { re: /"(?:\\.|[^"\\])*"(?=\s*:)/y, cls: 'pr' },
1450
+ { re: /"(?:\\.|[^"\\])*"/y, cls: 'st' },
1451
+ { re: /\b-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b/y, cls: 'nu' },
1452
+ { re: /\b(?:true|false|null)\b/y, cls: 'kw' }
1453
+ ],
1454
+ sql: [
1455
+ { re: /--[^\n]*|\/\*[\s\S]*?(?:\*\/|$)/y, cls: 'cm' },
1456
+ { re: /'(?:''|[^'])*'?/y, cls: 'st' },
1457
+ { re: /\b\d+(?:\.\d+)?\b/y, cls: 'nu' },
1458
+ { re: /\b(?:select|from|where|insert|into|values|update|set|delete|create|table|drop|alter|add|join|left|right|inner|outer|full|on|group|by|order|having|limit|offset|as|and|or|not|null|primary|key|foreign|references|default|unique|index|view|union|all|distinct|case|when|then|else|end|exists|between|like|in|asc|desc|count|sum|avg|min|max)\b/iy, cls: 'kw' }
1459
+ ],
1460
+ generic: [
1461
+ { re: /\/\*[\s\S]*?(?:\*\/|$)|\/\/[^\n]*|#[^\n]*/y, cls: 'cm' },
1462
+ { re: /"(?:\\.|[^"\\\n])*"?|'(?:\\.|[^'\\\n])*'?|`(?:\\.|[^`\\])*`?/y, cls: 'st' },
1463
+ { re: /\b0x[0-9a-fA-F]+\b|\b\d[\d_]*(?:\.\d+)?\b/y, cls: 'nu' },
1464
+ { re: /\b(?:if|else|for|while|return|function|class|def|import|export|from|const|var|let|new|true|false|null|nil|none|and|or|not|in|is|do|end|then|fi|case|switch|break|continue|try|catch|finally|throw|use|package|public|private|static)\b/iy, cls: 'kw' },
1465
+ { re: /\b[A-Za-z_]\w*(?=\s*\()/y, cls: 'fn' }
1466
+ ]
1467
+ };
1468
+
1469
+ function hlKind(lang) {
1470
+ var l = String(lang || '').toLowerCase();
1471
+ if (/^(js|jsx|javascript|ts|tsx|typescript|mjs|cjs|node)$/.test(l)) return 'js';
1472
+ if (/^(py|python|python3|ipython)$/.test(l)) return 'py';
1473
+ if (/^php$/.test(l)) return 'php';
1474
+ if (/^(html|xml|svg|vue|svelte)$/.test(l)) return 'html';
1475
+ if (/^(css|scss|sass|less)$/.test(l)) return 'css';
1476
+ if (/^(json|jsonc|json5)$/.test(l)) return 'json';
1477
+ if (/^(bash|sh|shell|zsh|console|shell-session|powershell|ps1|bat|cmd)$/.test(l)) return 'sh';
1478
+ if (/^sql$/.test(l)) return 'sql';
1479
+ if (/^(c|h|cpp|c\+\+|cc|hpp|java|cs|csharp|go|golang|rust|rs|kotlin|kt|swift|dart|scala|groovy|objc|objective-c)$/.test(l)) return 'clike';
1480
+ return 'generic';
1481
+ }
1482
+
1483
+ // En cada posición prueba las reglas en orden (sticky); lo que no casa va
1484
+ // como texto plano. Bloques enormes quedan sin resaltar (tope de seguridad).
1485
+ function hlCode(src, lang) {
1486
+ src = String(src || '');
1487
+ if (src.length > 60000) return esc(src);
1488
+ var rules = HL_RULES[hlKind(lang)] || HL_RULES.generic;
1489
+ var out = '';
1490
+ var plain = '';
1491
+ var pos = 0;
1492
+ var n = src.length;
1493
+ while (pos < n) {
1494
+ var hit = null;
1495
+ var cls = '';
1496
+ for (var i = 0; i < rules.length; i++) {
1497
+ var r = rules[i];
1498
+ r.re.lastIndex = pos;
1499
+ var m = r.re.exec(src);
1500
+ if (m && m[0]) { hit = m[0]; cls = r.cls; break; }
1501
+ }
1502
+ if (hit) {
1503
+ if (plain) { out += esc(plain); plain = ''; }
1504
+ out += '<span class="hl-' + cls + '">' + esc(hit) + '</span>';
1505
+ pos += hit.length;
1506
+ } else {
1507
+ plain += src[pos];
1508
+ pos++;
1509
+ }
1510
+ }
1511
+ if (plain) out += esc(plain);
1512
+ return out;
1513
+ }
1514
+
1515
+ function renderContent(text, query) {
1516
+ var parts = String(text || '').split('```');
1517
+ var out = '';
1518
+ for (var i = 0; i < parts.length; i++) {
1519
+ if (i % 2 === 1) {
1520
+ var block = parts[i];
1521
+ var nl = block.indexOf('\n');
1522
+ var lang = '';
1523
+ var body = block;
1524
+ if (nl >= 0 && nl <= 24 && /^[A-Za-z0-9_+#.\-]*$/.test(block.slice(0, nl).trim())) {
1525
+ lang = block.slice(0, nl).trim();
1526
+ body = block.slice(nl + 1);
1527
+ }
1528
+ out += '<div class="codewrap">'
1529
+ + '<div class="codehead"><span class="lang">' + esc(lang || 'código') + '</span>'
1530
+ + '<button type="button" class="copybtn" data-copy="code" title="Copiar código">⧉ Copiar</button></div>'
1531
+ + '<pre class="codeblock">' + hlCode(body.replace(/\s+$/, ''), lang) + '</pre></div>';
1532
+ } else {
1533
+ out += mdBlocks(parts[i]);
1534
+ }
1535
+ }
1536
+ return query ? highlightDom(out, query) : out;
1537
+ }
1538
+
1539
+ // ---------------------------------------------------------------------------
1540
+ // Chat
1541
+ // ---------------------------------------------------------------------------
1542
+ function stopProcTimer() {
1543
+ if (state.procTimer) { clearInterval(state.procTimer); state.procTimer = null; }
1544
+ }
1545
+
1546
+ function highlightText(text, query) {
1547
+ var q = String(query || '').trim();
1548
+ if (!q) return esc(text);
1549
+ var idx = text.toLowerCase().indexOf(q.toLowerCase());
1550
+ if (idx < 0) return esc(text);
1551
+ var before = esc(text.slice(0, idx));
1552
+ var match = esc(text.slice(idx, idx + q.length));
1553
+ var after = esc(text.slice(idx + q.length));
1554
+ return before + '<mark>' + match + '</mark>' + after;
1555
+ }
1556
+
1557
+ // Bloque 💭 del razonamiento: plegado en el historial; abierto mientras genera.
1558
+ function reasoningHtml(m, chatQuery) {
1559
+ if (m.role !== 'assistant' || !m.reasoning) return '';
1560
+ var open = m.status === 'streaming' ? ' open' : '';
1561
+ return '<details class="reasoning"' + open + '><summary>💭 razonamiento <span class="rsize">(' + fmtSize(m.reasoning.length) + ')</span></summary>'
1562
+ + '<div class="rbody">' + renderContent(m.reasoning, chatQuery) + '</div></details>';
1563
+ }
1564
+
1565
+ function nearBottom() {
1566
+ var el = els.messages;
1567
+ return el.scrollHeight - el.scrollTop - el.clientHeight < 140;
1568
+ }
1569
+
1570
+ // Altura real de la barra inferior visible (hoja cerrada): en móvil es el
1571
+ // botón que abre la hoja; además se suma la franja KITT si está abierta.
1572
+ function jumpBarH() {
1573
+ var h = 10;
1574
+ var bar = (isMobileView() && els.composeOpen && els.composeOpen.classList.contains('show')) ? els.composeOpen : els.sendForm;
1575
+ if (bar) h += bar.offsetHeight;
1576
+ if (els.procrow && els.procrow.classList.contains('open')) h += els.procrow.offsetHeight;
1577
+ return h;
1578
+ }
1579
+
1580
+ function onMsgScroll() {
1581
+ if (!els.jumpBtn || state.view !== 'chat') return;
1582
+ var nb = nearBottom();
1583
+ els.jumpBtn.classList.toggle('show', !nb);
1584
+ els.jumpBtn.style.bottom = jumpBarH() + 'px';
1585
+ }
1586
+
1587
+ // Huella por mensaje: si cambia (llegaron tramos, se canceló, etc.) se
1588
+ // reemplaza solo ese nodo; lo demás no se toca.
1589
+ function msgSig(m) {
1590
+ return (m.status || '') + '|' + (m.text || '').length + '|' + (m.reasoning || '').length
1591
+ + '|' + (m.canceled ? 1 : 0) + '|' + (m.agent || '') + '|' + (m.img ? m.img.length : 0);
1592
+ }
1593
+
1594
+ function msgNodeHtml(m, chatQuery) {
1595
+ var cls = m.role === 'user' ? 'mine' : 'theirs';
1596
+ if (m.status === 'canceled') cls += ' canceled';
1597
+ var who = m.role === 'user' ? '❯ vos' : '● agent';
1598
+ var agentBadge = m.agent ? ' <span class="abadge ' + esc(m.agent) + '">' + esc(m.agent) + '</span>' : '';
1599
+ var stopBadge = m.canceled ? ' <span class="stopbadge">⏹ detenido</span>' : '';
1600
+ var streaming = m.role === 'assistant' && m.status === 'streaming';
1601
+ var hit = chatQuery && (m.text || '').toLowerCase().indexOf(chatQuery.toLowerCase()) >= 0;
1602
+ return '<div class="msg ' + cls + (hit ? ' hit' : '') + '" data-mid="' + (m.id || '') + '" data-sig="' + esc(msgSig(m)) + '">'
1603
+ + '<div class="role"><span class="who">' + who + '</span>' + agentBadge + stopBadge
1604
+ + (streaming ? '<span class="genbadge">generando…</span>' : '')
1605
+ + '<span class="rmeta" title="' + esc(timeStr(m.ts)) + '">' + esc(timeOnly(m.ts)) + '</span>'
1606
+ + '<button type="button" class="copybtn" data-copy="msg" title="Copiar mensaje">⧉</button></div>'
1607
+ + '<div class="body">' + (m.img ? '<img class="msg-img" src="' + esc(m.img) + '" alt="imagen adjunta">' : '')
1608
+ + reasoningHtml(m, chatQuery) + renderContent(m.text || '', chatQuery)
1609
+ + (streaming ? '<span class="stream-cursor">▊</span>' : '')
1610
+ + '</div>'
1611
+ + '</div>';
1612
+ }
1613
+
1614
+ function daySepHtml(iso) {
1615
+ return '<div class="daysep"><span>' + esc(dayLabel(iso)) + '</span></div>';
1616
+ }
1617
+
1618
+ // Agrega al DOM solo los mensajes nuevos y reemplaza los que cambiaron.
1619
+ // Devuelve la cantidad de nodos agregados.
1620
+ function syncMessages(msgs, chatQuery) {
1621
+ var added = 0;
1622
+ var cont = els.messages;
1623
+ for (var i = 0; i < msgs.length; i++) {
1624
+ var m = msgs[i];
1625
+ var existing = cont.querySelector('.msg[data-mid="' + m.id + '"]');
1626
+ if (existing) {
1627
+ var sig = String(msgSig(m));
1628
+ if (existing.getAttribute('data-sig') !== sig) {
1629
+ var repl = document.createElement('div');
1630
+ repl.innerHTML = msgNodeHtml(m, chatQuery);
1631
+ existing.parentNode.replaceChild(repl.firstChild, existing);
1632
+ }
1633
+ continue;
1634
+ }
1635
+ var k = dayKey(m.ts);
1636
+ if (k !== state.lastDay) {
1637
+ var sep = document.createElement('div');
1638
+ sep.innerHTML = daySepHtml(m.ts);
1639
+ cont.appendChild(sep.firstChild);
1640
+ }
1641
+ state.lastDay = k;
1642
+ var wrap = document.createElement('div');
1643
+ wrap.innerHTML = msgNodeHtml(m, chatQuery);
1644
+ cont.appendChild(wrap.firstChild);
1645
+ added++;
1646
+ }
1647
+ return added;
1648
+ }
1649
+
1650
+ // Franja "opencode trabajando…" (barra KITT) fija sobre la barra de envío, con botón detener.
1651
+ function updateProcRow(msgs) {
1652
+ var row = els.procrow;
1653
+ if (!row) return;
1654
+ var lastUser = null;
1655
+ for (var wi = msgs.length - 1; wi >= 0; wi--) {
1656
+ if (msgs[wi].role === 'user') { lastUser = msgs[wi]; break; }
1657
+ }
1658
+ var waiting = !!lastUser && lastUser.status !== 'done' && lastUser.status !== 'error' && lastUser.status !== 'canceled';
1659
+ if (!waiting) {
1660
+ stopProcTimer();
1661
+ row.classList.remove('open');
1662
+ if (els.jumpBtn && els.jumpBtn.classList.contains('show')) els.jumpBtn.style.bottom = jumpBarH() + 'px';
1663
+ return;
1664
+ }
1665
+ var since = (new Date(lastUser.ts)).getTime();
1666
+ if (!isFinite(since)) since = Date.now();
1667
+ state.procSince = since;
1668
+ if (!row.classList.contains('open')) {
1669
+ var btn = document.getElementById('btnStop');
1670
+ if (btn) {
1671
+ btn.disabled = false;
1672
+ var tx = btn.querySelector('.stoptxt');
1673
+ if (tx) tx.textContent = 'detener';
1674
+ }
1675
+ row.classList.add('open');
1676
+ if (els.jumpBtn && els.jumpBtn.classList.contains('show')) els.jumpBtn.style.bottom = jumpBarH() + 'px';
1677
+ }
1678
+ if (!state.procTimer) {
1679
+ var tick = function () {
1680
+ var ind = document.getElementById('proc-ind');
1681
+ if (!ind) { stopProcTimer(); return; }
1682
+ var sec = Math.round((Date.now() - state.procSince) / 1000);
1683
+ var warn = sec >= 240;
1684
+ ind.className = 'typing proc' + (state.online ? '' : ' pause') + (warn ? ' warn' : '');
1685
+ row.classList.toggle('warn', warn);
1686
+ var lbl = document.getElementById('proc-label');
1687
+ if (lbl) {
1688
+ var txt = state.online ? (isMobileView() ? 'trabajando...' : 'opencode está trabajando...') : 'esperando el puente...';
1689
+ var tokTxt = sessionTokensLabel(state.currentSession || {});
1690
+ lbl.textContent = (warn ? '⚠ ' : '') + txt + (tokTxt ? ' · ' + tokTxt + ' tok' : '');
1691
+ }
1692
+ var tm = document.getElementById('proc-time');
1693
+ if (tm) tm.textContent = sec + 's';
1694
+ };
1695
+ tick();
1696
+ state.procTimer = setInterval(tick, 1000);
1697
+ }
1698
+ }
1699
+ if (els.procrow) document.getElementById('btnStop').addEventListener('click', cancelCurrentRun);
1700
+
1701
+ function renderChat(session, messages) {
1702
+ // Un chat abierto desde otra vista (búsqueda global, link directo) puede
1703
+ // pertenecer a otra PC: se pasa al puente dueño para ver su catálogo.
1704
+ var owner = session ? (session.bridge || '') : '';
1705
+ if (owner && owner !== activeBridgeId() && bridgeList().length > 1) {
1706
+ state.activeBridge = owner;
1707
+ storeBridge(owner);
1708
+ state.catalog = null;
1709
+ state.catVer = '';
1710
+ renderBridgeBar();
1711
+ refreshBridgeData();
1712
+ }
1713
+ state.messages = messages || [];
1714
+ if (session) {
1715
+ state.currentSession = session;
1716
+ if (session.folder) state.lastFolder = session.folder;
1717
+ els.hTitle.textContent = session.name || 'chat';
1718
+ els.hSub.textContent = sessionSubtitle(session);
1719
+ }
1720
+ var msgs = state.messages;
1721
+ var chatQuery = (state.chatSearch && state.chatSearch.query) || '';
1722
+ // Render completo solo al cambiar de chat o al buscar; el resto es incremental.
1723
+ var full = !!chatQuery || state.renderSid !== state.currentId;
1724
+ state.renderSid = state.currentId;
1725
+ var autoScroll = nearBottom();
1726
+ var addedCount = 0;
1727
+
1728
+ if (full) {
1729
+ var html = '';
1730
+ var prevDay = '';
1731
+ for (var i = 0; i < msgs.length; i++) {
1732
+ var k = dayKey(msgs[i].ts);
1733
+ if (k !== prevDay) { html += daySepHtml(msgs[i].ts); prevDay = k; }
1734
+ html += msgNodeHtml(msgs[i], chatQuery);
1735
+ }
1736
+ els.messages.innerHTML = html || '<div class="empty">Sin mensajes. Escribí algo.</div>';
1737
+ state.lastDay = prevDay;
1738
+ } else {
1739
+ addedCount = syncMessages(msgs, chatQuery);
1740
+ }
1741
+ updateProcRow(msgs);
1742
+
1743
+ if (full) {
1744
+ els.messages.scrollTop = els.messages.scrollHeight;
1745
+ if (els.jumpBtn) els.jumpBtn.classList.remove('show');
1746
+ if (chatQuery && state.chatSearch.firstHit) {
1747
+ var first = els.messages.querySelector('.msg.hit');
1748
+ if (first) first.scrollIntoView({ block: 'center', behavior: 'smooth' });
1749
+ }
1750
+ } else if (autoScroll) {
1751
+ els.messages.scrollTop = els.messages.scrollHeight;
1752
+ } else if (addedCount > 0 && els.jumpBtn) {
1753
+ els.jumpBtn.classList.add('show');
1754
+ els.jumpBtn.style.bottom = jumpBarH() + 'px';
1755
+ }
1756
+ renderSidebar();
1757
+ updateStatusbar();
1758
+ }
1759
+
1760
+ // Copiar mensajes y bloques de código (delegado, sobrevive a los re-renders).
1761
+ els.messages.addEventListener('click', function (e) {
1762
+ var btn = e.target.closest('.copybtn');
1763
+ if (!btn) return;
1764
+ var kind = btn.getAttribute('data-copy');
1765
+ if (kind === 'code') {
1766
+ var wrapEl = btn.closest('.codewrap');
1767
+ var pre = wrapEl ? wrapEl.querySelector('pre.codeblock') : null;
1768
+ if (pre) copyText(pre.textContent, 'código copiado');
1769
+ return;
1770
+ }
1771
+ var msgEl = btn.closest('.msg');
1772
+ if (!msgEl) return;
1773
+ var mid = parseInt(msgEl.getAttribute('data-mid'), 10);
1774
+ for (var i = 0; i < state.messages.length; i++) {
1775
+ if (state.messages[i].id === mid) {
1776
+ var extra = state.messages[i].img ? '\n[imagen adjunta]' : '';
1777
+ copyText((state.messages[i].text || '') + extra, 'mensaje copiado');
1778
+ return;
1779
+ }
1780
+ }
1781
+ });
1782
+
1783
+ if (els.jumpBtn) {
1784
+ els.jumpBtn.addEventListener('click', function () {
1785
+ try { els.messages.scrollTo({ top: els.messages.scrollHeight, behavior: 'smooth' }); }
1786
+ catch (e) { els.messages.scrollTop = els.messages.scrollHeight; }
1787
+ });
1788
+ }
1789
+
1790
+ // Refresco incremental: pide solo mensajes nuevos/cambiados (history?since=).
1791
+ // Con incremental=false (cambio de chat) trae el historial completo.
1792
+ async function loadHistory(sid, incremental) {
1793
+ var url = 'api.php?action=history&session=' + sid;
1794
+ var lastKnown = (!incremental || !state.messages.length) ? null
1795
+ : state.messages[state.messages.length - 1];
1796
+ if (lastKnown) {
1797
+ url += '&since=' + lastKnown.id + '&ts=' + encodeURIComponent(lastKnown.ts || '');
1798
+ }
1799
+ var data = await api(url);
1800
+ if (!data.ok) {
1801
+ toast('error al cargar el historial', 'error');
1802
+ return;
1803
+ }
1804
+ var msgs = data.messages || [];
1805
+ if (lastKnown) {
1806
+ var byId = {};
1807
+ for (var i = 0; i < state.messages.length; i++) byId[state.messages[i].id] = i;
1808
+ for (var j = 0; j < msgs.length; j++) {
1809
+ var u = msgs[j];
1810
+ if (byId[u.id] !== undefined) state.messages[byId[u.id]] = u;
1811
+ else { byId[u.id] = state.messages.length; state.messages.push(u); }
1812
+ }
1813
+ msgs = state.messages;
1814
+ }
1815
+ renderChat(data.session, msgs);
1816
+ }
1817
+
1818
+ function openChat(id) {
1819
+ state.currentId = id;
1820
+ state.chatSearch = { query: '', firstHit: false };
1821
+ if (state.view !== 'chat') state.prevView = state.view;
1822
+ showView('chat');
1823
+ els.messages.innerHTML = '<div class="empty">cargando…</div>';
1824
+ loadHistory(id);
1825
+ try { history.replaceState(null, '', 'chat.php?session=' + id); } catch (e) {}
1826
+ }
1827
+
1828
+ function goBack() {
1829
+ if (state.prevView === 'history' && state.histFolder !== null) {
1830
+ openHistory(state.histFolder);
1831
+ } else {
1832
+ goHome();
1833
+ }
1834
+ }
1835
+
1836
+ function goHome() {
1837
+ state.currentId = null;
1838
+ state.currentSession = null;
1839
+ state.chatSearch = null;
1840
+ showView('home');
1841
+ loadSessions();
1842
+ }
1843
+
1844
+ // Sidebar: tocá un proyecto → lo expande y resalta en la vista de chats.
1845
+ function focusProject(folder) {
1846
+ state.focusFolder = folder;
1847
+ state.focusFlash = folder;
1848
+ setProjectOpen(folder, true);
1849
+ if (state.view === 'home' && state.sessions.length) {
1850
+ renderHome(state.sessions);
1851
+ renderSidebar();
1852
+ } else {
1853
+ showView('home');
1854
+ loadSessions();
1855
+ }
1856
+ }
1857
+
1858
+ // Historial de un proyecto: vista aparte con buscador dentro del proyecto.
1859
+ function openHistory(folder) {
1860
+ state.histFolder = folder;
1861
+ state.histLabel = projectLabel(folder) || '(sin proyecto)';
1862
+ state.histQuery = '';
1863
+ showView('history');
1864
+ }
1865
+
1866
+ function histFilteredSessions() {
1867
+ var f = state.histFolder;
1868
+ var q = String(state.histQuery || '').trim().toLowerCase();
1869
+ var out = [];
1870
+ for (var i = 0; i < state.sessions.length; i++) {
1871
+ var s = state.sessions[i];
1872
+ if (s.folder !== f) continue;
1873
+ if (q) {
1874
+ var hay = ((s.name || '') + ' ' + (s.preview || '') + ' ' + (s.model || '')).toLowerCase();
1875
+ if (hay.indexOf(q) < 0) continue;
1876
+ }
1877
+ out.push(s);
1878
+ }
1879
+ return out;
1880
+ }
1881
+
1882
+ function renderHistory() {
1883
+ if (!els.viewHistory) return;
1884
+ if (state.histFolder === null) {
1885
+ els.viewHistory.innerHTML = '<div class="placeholder">Elegí un proyecto desde el sidebar.</div>';
1886
+ return;
1887
+ }
1888
+ var label = state.histLabel || projectLabel(state.histFolder) || '(sin proyecto)';
1889
+ els.viewHistory.innerHTML =
1890
+ '<div class="view-head"><h2>historial · ' + esc(label) + '</h2></div>'
1891
+ + '<div class="files-toolbar">'
1892
+ + '<div class="search-bar" style="flex:1;margin:0">'
1893
+ + '<span>🔍</span>'
1894
+ + '<input type="text" id="histInput" placeholder="buscar en este proyecto…" autocomplete="off" value="' + esc(state.histQuery) + '">'
1895
+ + '</div>'
1896
+ + '</div>'
1897
+ + '<div id="histList"></div>';
1898
+ var input = document.getElementById('histInput');
1899
+ if (input) {
1900
+ input.addEventListener('input', function () {
1901
+ state.histQuery = input.value;
1902
+ renderHistList();
1903
+ });
1904
+ }
1905
+ renderHistList();
1906
+ }
1907
+
1908
+ function renderHistList() {
1909
+ var box = document.getElementById('histList');
1910
+ if (!box) return;
1911
+ var list = histFilteredSessions();
1912
+ var totalInProj = 0;
1913
+ for (var i = 0; i < state.sessions.length; i++) {
1914
+ if (state.sessions[i].folder === state.histFolder) totalInProj++;
1915
+ }
1916
+ var html = '<div class="search-summary">' + list.length + ' de ' + totalInProj + ' sesión(es)</div>';
1917
+ if (!list.length) {
1918
+ html += '<div class="placeholder">Sin coincidencias en este proyecto.</div>';
1919
+ box.innerHTML = html;
1920
+ return;
1921
+ }
1922
+ html += '<div class="cards">';
1923
+ for (var j = 0; j < list.length; j++) {
1924
+ var s = list[j];
1925
+ var busy = s.state === 'working'
1926
+ ? '<span class="sbusy" title="trabajando ahora…"></span> '
1927
+ : (s.state === 'waiting' ? '<span style="color:var(--mine)">●</span> ' : '');
1928
+ html += '<div class="card" data-sid="' + s.id + '">'
1929
+ + '<div class="grow">'
1930
+ + '<div class="cname">' + esc(s.name) + '</div>'
1931
+ + '<div class="cmeta">' + busy + esc(s.model || 'sin modelo') + ' · ' + esc(s.agent || 'build') + ' · ' + esc(timeShort(s.last_ts)) + '</div>'
1932
+ + (s.preview ? '<div class="cpreview">' + esc(s.preview) + '</div>' : '')
1933
+ + '</div>'
1934
+ + '</div>';
1935
+ }
1936
+ html += '</div>';
1937
+ box.innerHTML = html;
1938
+ var cards = box.querySelectorAll('.card');
1939
+ for (var c = 0; c < cards.length; c++) {
1940
+ (function (card) {
1941
+ card.addEventListener('click', function () {
1942
+ openChat(parseInt(card.getAttribute('data-sid'), 10));
1943
+ });
1944
+ })(cards[c]);
1945
+ }
1946
+ }
1947
+
1948
+ // ---------------------------------------------------------------------------
1949
+ // Modal nueva sesión
1950
+ // ---------------------------------------------------------------------------
1951
+ function applyCatalogPayload(data) {
1952
+ if (!data || !data.ok) return false;
1953
+ var changed = false;
1954
+ if (data.catalog) {
1955
+ // Puede llegar un catálogo de otro puente (SSE): se ignora.
1956
+ if (data.bridge !== undefined && data.bridge !== activeBridgeId()) {
1957
+ if (Array.isArray(data.bridges)) applyBridges(data.bridges);
1958
+ return false;
1959
+ }
1960
+ state.catalog = data.catalog;
1961
+ if (data.cat_ver) state.catVer = data.cat_ver;
1962
+ lsSet(catalogKey(), state.catalog);
1963
+ lsSet(catalogVerKey(), state.catVer);
1964
+ fillSelects();
1965
+ changed = true;
1966
+ } else if (data.cat_ver) {
1967
+ state.catVer = data.cat_ver;
1968
+ }
1969
+ if (Array.isArray(data.bridges)) applyBridges(data.bridges);
1970
+ if (data.online_ts && state.catalog) {
1971
+ state.catalog.last_online_ts = data.online_ts;
1972
+ }
1973
+ applyOnlineUI();
1974
+ return changed;
1975
+ }
1976
+
1977
+ // Descarga el catálogo del puente activo (condicionado por versión).
1978
+ async function refreshActiveCatalog() {
1979
+ var data = await api('api.php?action=catalog&v=' + encodeURIComponent(state.catVer || ''));
1980
+ if (data.ok) applyCatalogPayload(data);
1981
+ }
1982
+
1983
+ // El catálogo se mantiene fresco por bootstrap/SSE/poll; esta función ya no
1984
+ // re-descarga en cada modal, solo garantiza que exista.
1985
+ async function loadCatalog() {
1986
+ if (!state.catalog) {
1987
+ var data = await api('api.php?action=catalog');
1988
+ applyCatalogPayload(data);
1989
+ }
1990
+ updateStatusbar();
1991
+ }
1992
+
1993
+ // ---------------------------------------------------------------------------
1994
+ // Selects de modelos: favoritos (config del puente) + catálogo completo
1995
+ // agrupado por proveedor, con buscador opcional.
1996
+ // ---------------------------------------------------------------------------
1997
+ function selectHasValue(select, val) {
1998
+ var opts = select.options;
1999
+ for (var i = 0; i < opts.length; i++) {
2000
+ if (opts[i].value === val) return true;
2001
+ }
2002
+ return false;
2003
+ }
2004
+
2005
+ function fillModelOptions(select, searchInput, current, onResult) {
2006
+ if (!select) return 0;
2007
+ var c = state.catalog || {};
2008
+ var favs = Array.isArray(c.models) ? c.models.slice() : [];
2009
+ var groups = (c.models_full && typeof c.models_full === 'object') ? c.models_full : {};
2010
+ var vision = Array.isArray(c.vision) ? c.vision : [];
2011
+ var q = searchInput ? String(searchInput.value || '').trim().toLowerCase() : '';
2012
+ function matches(id) { return !q || id.toLowerCase().indexOf(q) >= 0; }
2013
+ function addGroup(label, ids, prefix) {
2014
+ var og = document.createElement('optgroup');
2015
+ og.label = label;
2016
+ for (var i = 0; i < ids.length; i++) {
2017
+ var id = ids[i];
2018
+ var o = document.createElement('option');
2019
+ o.value = id;
2020
+ o.textContent = (vision.indexOf(id) >= 0 ? '👁 ' : '')
2021
+ + (prefix && id.indexOf(prefix + '/') === 0 ? id.slice(prefix.length + 1) : id);
2022
+ og.appendChild(o);
2023
+ }
2024
+ select.appendChild(og);
2025
+ }
2026
+ select.innerHTML = '';
2027
+ var total = 0;
2028
+ var fvis = favs.filter(matches);
2029
+ if (fvis.length) { addGroup('favoritos', fvis, null); total += fvis.length; }
2030
+ var provs = Object.keys(groups).sort(function (a, b) { return a.localeCompare(b); });
2031
+ for (var i = 0; i < provs.length; i++) {
2032
+ var p = provs[i];
2033
+ var list = (Array.isArray(groups[p]) ? groups[p] : []).filter(matches);
2034
+ if (!list.length) continue;
2035
+ addGroup(p + ' (' + list.length + ')', list, p);
2036
+ total += list.length;
2037
+ }
2038
+ // El modelo actual puede no estar en la lista (ej. favorito quitado del config).
2039
+ if (current && favs.indexOf(current) < 0 && !selectHasValue(select, current)) {
2040
+ addGroup('actual', [current], null);
2041
+ total += 1;
2042
+ }
2043
+ if (current && selectHasValue(select, current)) {
2044
+ select.value = current;
2045
+ }
2046
+ if (select.selectedIndex < 0 && select.options.length) {
2047
+ select.selectedIndex = 0;
2048
+ }
2049
+ if (onResult) onResult(total, q);
2050
+ return total;
2051
+ }
2052
+
2053
+ function fillSelects() {
2054
+ var c = state.catalog || { folders: [], models: [], agents: [] };
2055
+ var folders = c.folders || [];
2056
+ var agents = c.agents && c.agents.length ? c.agents : ['build', 'plan'];
2057
+
2058
+ var prevValue = els.fFolder.value;
2059
+ els.fFolder.innerHTML = '';
2060
+ for (var i = 0; i < folders.length; i++) {
2061
+ var fo = document.createElement('option');
2062
+ fo.value = folders[i].path;
2063
+ fo.textContent = folders[i].name;
2064
+ els.fFolder.appendChild(fo);
2065
+ }
2066
+
2067
+ var modelCount = fillModelOptions(els.fModel, els.fModelSearch, els.fModel.value, null);
2068
+ if (els.fModelSearch) {
2069
+ els.fModelSearch.oninput = function () {
2070
+ fillModelOptions(els.fModel, els.fModelSearch, els.fModel.value, null);
2071
+ };
2072
+ }
2073
+
2074
+ els.fAgent.innerHTML = '';
2075
+ for (var k = 0; k < agents.length; k++) {
2076
+ var ao = document.createElement('option');
2077
+ ao.value = agents[k];
2078
+ ao.textContent = agents[k] === 'plan' ? 'Plan (solo analiza)'
2079
+ : agents[k] === 'build' ? 'Build (construye y edita)'
2080
+ : agents[k];
2081
+ els.fAgent.appendChild(ao);
2082
+ }
2083
+
2084
+ // Preselección: carpeta del último chat activo si sigue disponible.
2085
+ if (state.lastFolder) {
2086
+ var found = false;
2087
+ for (var x = 0; x < els.fFolder.options.length; x++) {
2088
+ if (els.fFolder.options[x].value === state.lastFolder) { found = true; break; }
2089
+ }
2090
+ if (found) els.fFolder.value = state.lastFolder;
2091
+ } else if (prevValue) {
2092
+ els.fFolder.value = prevValue;
2093
+ }
2094
+
2095
+ els.fFolderCreate.style.display = c.allow_create_folders && c.workspace ? '' : 'none';
2096
+ els.fNewFolderMsg.textContent = '';
2097
+
2098
+ var ready = folders.length > 0 && modelCount > 0 && agents.length > 0;
2099
+ els.fWarn.style.display = ready ? 'none' : '';
2100
+ els.btnSave.disabled = !ready;
2101
+ }
2102
+
2103
+ function openModal(preselectFolder) {
2104
+ loadCatalog().then(function () {
2105
+ els.overlay.classList.add('show');
2106
+ els.fMsg.value = '';
2107
+ els.fNewFolder.value = '';
2108
+ els.fNewFolderMsg.textContent = '';
2109
+ if (preselectFolder) {
2110
+ for (var i = 0; i < els.fFolder.options.length; i++) {
2111
+ if (els.fFolder.options[i].value === preselectFolder) {
2112
+ els.fFolder.value = preselectFolder;
2113
+ break;
2114
+ }
2115
+ }
2116
+ }
2117
+ // Carpeta y modelo suelen venir preseleccionados: el foco va al mensaje.
2118
+ try { els.fMsg.focus(); } catch (e) {}
2119
+ });
2120
+ }
2121
+
2122
+ function closeModal() {
2123
+ els.overlay.classList.remove('show');
2124
+ }
2125
+
2126
+ els.btnNewSession.addEventListener('click', function () {
2127
+ document.body.classList.remove('sidebar-open');
2128
+ openModal();
2129
+ });
2130
+
2131
+ if (els.fabNew) {
2132
+ els.fabNew.addEventListener('click', openModal);
2133
+ }
2134
+
2135
+ els.btnCancel.addEventListener('click', closeModal);
2136
+ els.overlay.addEventListener('click', function (e) { if (e.target === els.overlay) closeModal(); });
2137
+
2138
+ els.btnSave.addEventListener('click', async function () {
2139
+ var folder = els.fFolder.value;
2140
+ var model = els.fModel.value;
2141
+ var agent = els.fAgent.value;
2142
+ if (!folder || !model || !agent) return;
2143
+ var first = els.fMsg.value.trim();
2144
+ els.btnSave.disabled = true;
2145
+ try {
2146
+ // El nombre lo pone el auto-título con el primer mensaje (o "Chat N").
2147
+ var data = await api('api.php?action=session_create', apiCsrf('POST', { folder: folder, model: model, agent: agent }));
2148
+ if (data.ok) {
2149
+ state.lastFolder = folder;
2150
+ closeModal();
2151
+ openChat(data.session.id);
2152
+ loadSessions();
2153
+ if (first) {
2154
+ els.input.value = first;
2155
+ autoGrow();
2156
+ els.sendForm.dispatchEvent(new Event('submit', { cancelable: true }));
2157
+ }
2158
+ } else {
2159
+ toast(data.error || 'no se pudo crear la sesión', 'error');
2160
+ }
2161
+ } finally {
2162
+ els.btnSave.disabled = false;
2163
+ }
2164
+ });
2165
+
2166
+ // Enter en el mensaje = crear (shift+enter = salto de línea).
2167
+ els.fMsg.addEventListener('keydown', function (e) {
2168
+ if (e.key === 'Enter' && !e.shiftKey) {
2169
+ e.preventDefault();
2170
+ if (!els.btnSave.disabled) els.btnSave.click();
2171
+ }
2172
+ });
2173
+
2174
+ els.btnNewFolder.addEventListener('click', async function () {
2175
+ var name = els.fNewFolder.value.trim();
2176
+ if (!name) return;
2177
+ els.fNewFolderMsg.textContent = 'enviando solicitud…';
2178
+ els.btnNewFolder.disabled = true;
2179
+ var data = await api('api.php?action=request_folder', apiCsrf('POST', { name: name }));
2180
+ if (!data.ok) {
2181
+ els.fNewFolderMsg.textContent = data.error || 'error';
2182
+ els.btnNewFolder.disabled = false;
2183
+ return;
2184
+ }
2185
+ els.fNewFolderMsg.textContent = 'creando carpeta „' + name + '“…';
2186
+ var tries = 0;
2187
+ var timer = setInterval(async function () {
2188
+ tries++;
2189
+ var cat = await api('api.php?action=catalog&v=' + encodeURIComponent(state.catVer || ''));
2190
+ if (cat.ok) {
2191
+ applyCatalogPayload(cat);
2192
+ }
2193
+ var found = null;
2194
+ var list = (state.catalog && state.catalog.folders) || [];
2195
+ for (var i = 0; i < list.length; i++) {
2196
+ if (list[i].name === name || list[i].path.indexOf(name) >= 0) { found = list[i]; break; }
2197
+ }
2198
+ if (found) {
2199
+ clearInterval(timer);
2200
+ els.fNewFolderMsg.textContent = '';
2201
+ els.btnNewFolder.disabled = false;
2202
+ state.lastFolder = found.path;
2203
+ fillSelects();
2204
+ els.fFolder.value = found.path;
2205
+ } else if (tries > 15) {
2206
+ clearInterval(timer);
2207
+ els.btnNewFolder.disabled = false;
2208
+ els.fNewFolderMsg.textContent = 'tardó demasiado. ¿Está el puente iniciado?';
2209
+ } else {
2210
+ els.fNewFolderMsg.textContent = 'creando carpeta „' + name + '“… (intento ' + tries + ')';
2211
+ }
2212
+ }, 2000);
2213
+ });
2214
+
2215
+ // ---------------------------------------------------------------------------
2216
+ // Cambiar modelo / agente del chat activo
2217
+ // ---------------------------------------------------------------------------
2218
+ function fillModelModal(c) {
2219
+ var cur = state.currentSession || {};
2220
+ var agents = (c && c.agents && c.agents.length) ? c.agents : ['build', 'plan'];
2221
+ var fill = function () {
2222
+ var keep = els.fModel2.value || cur.model || '';
2223
+ fillModelOptions(els.fModel2, els.mSearch, keep, function (total, q) {
2224
+ if (!total) {
2225
+ els.mWarn.textContent = q ? 'sin coincidencias para “' + q + '”.' : 'el puente aún no sincronizó modelos.';
2226
+ els.mWarn.style.display = '';
2227
+ } else {
2228
+ els.mWarn.style.display = 'none';
2229
+ }
2230
+ els.btnModelSave.disabled = !total;
2231
+ });
2232
+ };
2233
+ fill();
2234
+ if (els.mSearch) {
2235
+ els.mSearch.value = '';
2236
+ els.mSearch.oninput = fill;
2237
+ }
2238
+ els.fAgent2.innerHTML = '';
2239
+ for (var j = 0; j < agents.length; j++) {
2240
+ var ao = document.createElement('option');
2241
+ ao.value = agents[j];
2242
+ ao.textContent = agents[j] === 'plan' ? 'Plan (solo analiza)'
2243
+ : agents[j] === 'build' ? 'Build (construye y edita)'
2244
+ : agents[j];
2245
+ els.fAgent2.appendChild(ao);
2246
+ }
2247
+ if (cur.agent) {
2248
+ for (var y = 0; y < els.fAgent2.options.length; y++) {
2249
+ if (els.fAgent2.options[y].value === cur.agent) { els.fAgent2.value = cur.agent; break; }
2250
+ }
2251
+ }
2252
+ }
2253
+
2254
+ function openModelModal() {
2255
+ if (state.currentId === null || !state.currentSession) return;
2256
+ loadCatalog().then(function () {
2257
+ fillModelModal(state.catalog);
2258
+ els.mTitle.textContent = (state.currentSession.name || ('chat ' + state.currentId)) + ' · ' + projectLabel(state.currentSession.folder);
2259
+ els.modelOverlay.classList.add('show');
2260
+ });
2261
+ }
2262
+
2263
+ function closeModelModal() {
2264
+ els.modelOverlay.classList.remove('show');
2265
+ }
2266
+
2267
+ els.sbModel.addEventListener('click', openModelModal);
2268
+ els.btnModelCancel.addEventListener('click', closeModelModal);
2269
+ els.modelOverlay.addEventListener('click', function (e) { if (e.target === els.modelOverlay) closeModelModal(); });
2270
+
2271
+ els.btnModelSave.addEventListener('click', async function () {
2272
+ var model = els.fModel2.value;
2273
+ var agent = els.fAgent2.value;
2274
+ if (!model || !agent || state.currentId === null) return;
2275
+ els.btnModelSave.disabled = true;
2276
+ var data = await api('api.php?action=session_update', apiCsrf('POST', { id: state.currentId, model: model, agent: agent }));
2277
+ els.btnModelSave.disabled = false;
2278
+ if (data.ok && data.session) {
2279
+ state.currentSession = data.session;
2280
+ els.hSub.textContent = sessionSubtitle(data.session);
2281
+ updateStatusbar();
2282
+ closeModelModal();
2283
+ } else {
2284
+ toast(data.error || 'error al guardar', 'error');
2285
+ }
2286
+ });
2287
+
2288
+ // ---------------------------------------------------------------------------
2289
+ // Cambiar agente del chat activo (Ctrl+. alterna plan ↔ build)
2290
+ // ---------------------------------------------------------------------------
2291
+ async function toggleAgent() {
2292
+ if (state.currentId === null || !state.currentSession) return;
2293
+ var cur = state.currentSession.agent || 'build';
2294
+ var next = cur === 'plan' ? 'build' : 'plan';
2295
+ var agents = (state.catalog && state.catalog.agents && state.catalog.agents.length)
2296
+ ? state.catalog.agents : ['build', 'plan'];
2297
+ if (agents.indexOf(next) < 0) {
2298
+ toast('el agente "' + next + '" no está disponible', 'error');
2299
+ return;
2300
+ }
2301
+ var data = await api('api.php?action=session_update', apiCsrf('POST', { id: state.currentId, agent: next }));
2302
+ if (data.ok && data.session) {
2303
+ state.currentSession = data.session;
2304
+ els.hSub.textContent = sessionSubtitle(data.session);
2305
+ updateStatusbar();
2306
+ els.sbModel.classList.remove('flash');
2307
+ void els.sbModel.offsetWidth; // reinicia la animación
2308
+ els.sbModel.classList.add('flash');
2309
+ toast('agente → ' + next, 'ok');
2310
+ } else {
2311
+ toast(data.error || 'error al cambiar agente', 'error');
2312
+ }
2313
+ }
2314
+
2315
+ // ---------------------------------------------------------------------------
2316
+ // Imagen adjunta (modelos con visión)
2317
+ // ---------------------------------------------------------------------------
2318
+ function setPendingImage(dataUrl) {
2319
+ state.pendingImage = dataUrl;
2320
+ if (dataUrl) {
2321
+ els.imgThumb.src = dataUrl;
2322
+ els.imgMeta.textContent = fmtSize(dataUrl.length * 3 / 4);
2323
+ els.imgPreview.style.display = 'flex';
2324
+ } else {
2325
+ els.imgInput.value = '';
2326
+ els.imgPreview.style.display = 'none';
2327
+ els.imgThumb.src = '';
2328
+ els.imgMeta.textContent = '';
2329
+ }
2330
+ }
2331
+
2332
+ function modelHasVision(model) {
2333
+ var c = state.catalog || {};
2334
+ return Array.isArray(c.vision) && c.vision.indexOf(model) >= 0;
2335
+ }
2336
+
2337
+ if (els.btnImg) {
2338
+ els.btnImg.addEventListener('click', function () {
2339
+ if (state.currentId === null) return;
2340
+ els.imgInput.click();
2341
+ });
2342
+ els.imgInput.addEventListener('change', function () {
2343
+ var f = els.imgInput.files && els.imgInput.files[0];
2344
+ if (!f) return;
2345
+ if (!/^image\/(png|jpe?g|webp|gif)$/.test(f.type)) {
2346
+ alert('Formato no soportado: usá PNG, JPG, WebP o GIF.');
2347
+ els.imgInput.value = '';
2348
+ return;
2349
+ }
2350
+ if (f.size > 4 * 1024 * 1024) {
2351
+ alert('Imagen demasiado grande (máx 4 MB).');
2352
+ els.imgInput.value = '';
2353
+ return;
2354
+ }
2355
+ var model = (state.currentSession && state.currentSession.model) || '';
2356
+ if (model && !modelHasVision(model) && !confirm('El modelo "' + model + '" no declara soporte de imágenes (👁) y puede fallar. ¿Adjuntar igual?')) {
2357
+ els.imgInput.value = '';
2358
+ return;
2359
+ }
2360
+ var fr = new FileReader();
2361
+ fr.onload = function () { setPendingImage(String(fr.result || '')); };
2362
+ fr.readAsDataURL(f);
2363
+ });
2364
+ els.imgRemove.addEventListener('click', function () { setPendingImage(null); });
2365
+ }
2366
+
2367
+ // ---------------------------------------------------------------------------
2368
+ // Composer: textarea multilínea (Enter envía, Shift+Enter salta de línea)
2369
+ // y menú de comandos "/" con filtro, flechas y Enter.
2370
+ // ---------------------------------------------------------------------------
2371
+ function autoGrow() {
2372
+ var el = els.input;
2373
+ if (!el.scrollHeight) return; // oculta (chat no visible): no fijar altura
2374
+ el.style.height = 'auto';
2375
+ var max = 148;
2376
+ el.style.height = Math.min(el.scrollHeight, max) + 'px';
2377
+ el.style.overflowY = el.scrollHeight > max ? 'auto' : 'hidden';
2378
+ if (els.jumpBtn) els.jumpBtn.style.bottom = jumpBarH() + 'px';
2379
+ }
2380
+
2381
+ var slash = { open: false, items: [], sel: 0 };
2382
+ function slashFilter(q) {
2383
+ q = String(q || '').trim().toLowerCase();
2384
+ return CMDS.filter(function (c) {
2385
+ return c.cmd.indexOf(q) === 0 || c.desc.toLowerCase().indexOf(q) >= 0;
2386
+ });
2387
+ }
2388
+ function renderSlash() {
2389
+ var html = '';
2390
+ for (var i = 0; i < slash.items.length; i++) {
2391
+ var c = slash.items[i];
2392
+ html += '<div class="item' + (i === slash.sel ? ' sel' : '') + '" data-i="' + i + '">'
2393
+ + '<b>' + esc(c.cmd) + '</b><span>' + esc(c.desc) + '</span></div>';
2394
+ }
2395
+ els.slashPanel.innerHTML = html || '<div class="item"><span>sin comandos que coincidan</span></div>';
2396
+ }
2397
+ function closeSlash() {
2398
+ if (!slash.open) return;
2399
+ slash.open = false;
2400
+ els.slashPanel.style.display = 'none';
2401
+ }
2402
+ function applySlash(cmd) {
2403
+ els.input.value = cmd + ' ';
2404
+ closeSlash();
2405
+ autoGrow();
2406
+ els.input.focus();
2407
+ try { els.input.setSelectionRange(els.input.value.length, els.input.value.length); } catch (e) {}
2408
+ }
2409
+
2410
+ els.input.addEventListener('input', function () {
2411
+ autoGrow();
2412
+ var v = els.input.value;
2413
+ if (state.view === 'chat' && v.charAt(0) === '/') {
2414
+ slash.items = slashFilter(v);
2415
+ slash.sel = 0;
2416
+ renderSlash();
2417
+ els.slashPanel.style.display = 'block';
2418
+ slash.open = true;
2419
+ } else {
2420
+ closeSlash();
2421
+ }
2422
+ });
2423
+
2424
+ els.input.addEventListener('keydown', function (e) {
2425
+ if (slash.open) {
2426
+ if (e.key === 'ArrowDown' && slash.items.length) {
2427
+ e.preventDefault();
2428
+ slash.sel = (slash.sel + 1) % slash.items.length;
2429
+ renderSlash();
2430
+ return;
2431
+ }
2432
+ if (e.key === 'ArrowUp' && slash.items.length) {
2433
+ e.preventDefault();
2434
+ slash.sel = (slash.sel - 1 + slash.items.length) % slash.items.length;
2435
+ renderSlash();
2436
+ return;
2437
+ }
2438
+ if ((e.key === 'Enter' || e.key === 'Tab') && slash.items.length) {
2439
+ e.preventDefault();
2440
+ e.stopPropagation();
2441
+ applySlash(slash.items[slash.sel].cmd);
2442
+ return;
2443
+ }
2444
+ if (e.key === 'Escape') {
2445
+ e.stopPropagation();
2446
+ closeSlash();
2447
+ return;
2448
+ }
2449
+ }
2450
+ if (e.key === 'Enter' && !e.shiftKey) {
2451
+ e.preventDefault();
2452
+ els.sendForm.dispatchEvent(new Event('submit', { cancelable: true }));
2453
+ }
2454
+ });
2455
+
2456
+ els.slashPanel.addEventListener('click', function (e) {
2457
+ var it = e.target.closest('.item');
2458
+ if (!it || it.getAttribute('data-i') === null) return;
2459
+ applySlash(slash.items[parseInt(it.getAttribute('data-i'), 10)].cmd);
2460
+ });
2461
+
2462
+ // ---------------------------------------------------------------------------
2463
+ // Enviar mensaje
2464
+ // ---------------------------------------------------------------------------
2465
+ els.sendForm.addEventListener('submit', async function (ev) {
2466
+ ev.preventDefault();
2467
+ if (state.currentId === null) return;
2468
+ if (!state.online) {
2469
+ toast('el puente está apagado. Iniciá “OpenBridge” en tu PC.', 'error');
2470
+ return;
2471
+ }
2472
+ var text = els.input.value.trim();
2473
+ if ((!text && !state.pendingImage) || state.isSending) return;
2474
+ state.isSending = true;
2475
+ try {
2476
+ var body = { session: state.currentId, text: text };
2477
+ if (state.pendingImage) body.image = state.pendingImage;
2478
+ var data = await api('api.php?action=send', apiCsrf('POST', body));
2479
+ if (data.ok) {
2480
+ els.input.value = '';
2481
+ autoGrow();
2482
+ closeSlash();
2483
+ setPendingImage(null);
2484
+ await loadHistory(state.currentId, true);
2485
+ } else {
2486
+ toast(data.error || 'error al enviar', 'error');
2487
+ }
2488
+ } catch (e) {
2489
+ toast('error al enviar', 'error');
2490
+ } finally {
2491
+ state.isSending = false;
2492
+ if (isMobileView()) composeCloseSheet();
2493
+ else els.input.focus();
2494
+ }
2495
+ });
2496
+
2497
+ // ---------------------------------------------------------------------------
2498
+ // Cancelar la ejecución en curso (el puente corta opencode en ~2 s)
2499
+ // ---------------------------------------------------------------------------
2500
+ async function cancelCurrentRun() {
2501
+ if (state.currentId === null) return;
2502
+ var btn = document.getElementById('btnStop');
2503
+ if (btn) {
2504
+ btn.disabled = true;
2505
+ var tx = btn.querySelector('.stoptxt');
2506
+ if (tx) tx.textContent = 'cancelando...';
2507
+ }
2508
+ await api('api.php?action=cancel', apiCsrf('POST', { session: state.currentId }));
2509
+ if (state.currentId !== null) await loadHistory(state.currentId, true);
2510
+ }
2511
+
2512
+ // ---------------------------------------------------------------------------
2513
+ // Buscador dentro del chat activo
2514
+ // ---------------------------------------------------------------------------
2515
+ var chatSearchDebounce = null;
2516
+ function bindChatSearch() {
2517
+ document.addEventListener('keydown', function (e) {
2518
+ if ((e.ctrlKey || e.metaKey) && (e.key === '.' || e.code === 'Period')) {
2519
+ if (state.view === 'chat' && state.currentId !== null) {
2520
+ e.preventDefault();
2521
+ toggleAgent();
2522
+ }
2523
+ return;
2524
+ }
2525
+ if (e.key === 'Escape') {
2526
+ if (els.sendForm.classList.contains('open')) {
2527
+ composeCloseSheet();
2528
+ return;
2529
+ }
2530
+ if (els.cardMenu && els.cardMenu.classList.contains('open')) {
2531
+ closeCardMenu();
2532
+ return;
2533
+ }
2534
+ if (els.modelOverlay && els.modelOverlay.classList.contains('show')) {
2535
+ closeModelModal();
2536
+ return;
2537
+ }
2538
+ }
2539
+ if (state.view !== 'chat') return;
2540
+ if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
2541
+ e.preventDefault();
2542
+ ensureChatSearchBar();
2543
+ var input = document.getElementById('chatSearchInput');
2544
+ if (input) { input.focus(); input.select(); }
2545
+ } else if (e.key === 'Escape' && state.chatSearch) {
2546
+ state.chatSearch = null;
2547
+ removeChatSearchBar();
2548
+ if (state.currentId) loadHistory(state.currentId, true);
2549
+ }
2550
+ });
2551
+ }
2552
+
2553
+ function ensureChatSearchBar() {
2554
+ if (document.getElementById('chatSearchBar')) return;
2555
+ var bar = document.createElement('div');
2556
+ bar.id = 'chatSearchBar';
2557
+ bar.className = 'search-bar';
2558
+ bar.innerHTML = '<span>🔍</span><input type="text" id="chatSearchInput" placeholder="buscar en este chat… (Esc para salir)"><span id="chatSearchCount"></span><button type="button" id="chatSearchClose" aria-label="Cerrar">×</button>';
2559
+ var sendbar = document.querySelector('form.sendbar');
2560
+ els.chat.insertBefore(bar, sendbar);
2561
+ document.getElementById('chatSearchClose').addEventListener('click', function () {
2562
+ state.chatSearch = null;
2563
+ removeChatSearchBar();
2564
+ if (state.currentId) loadHistory(state.currentId, true);
2565
+ });
2566
+ var input = document.getElementById('chatSearchInput');
2567
+ input.addEventListener('input', function () {
2568
+ clearTimeout(chatSearchDebounce);
2569
+ var q = input.value;
2570
+ chatSearchDebounce = setTimeout(function () {
2571
+ if (!q) {
2572
+ state.chatSearch = null;
2573
+ if (state.currentId) loadHistory(state.currentId, true);
2574
+ document.getElementById('chatSearchCount').textContent = '';
2575
+ return;
2576
+ }
2577
+ state.chatSearch = { query: q, firstHit: true };
2578
+ loadHistory(state.currentId, true);
2579
+ setTimeout(function () {
2580
+ var hits = els.messages.querySelectorAll('.msg.hit').length;
2581
+ document.getElementById('chatSearchCount').textContent = hits + (hits === 1 ? ' coincidencia' : ' coincidencias');
2582
+ }, 50);
2583
+ }, 220);
2584
+ });
2585
+ }
2586
+
2587
+ function removeChatSearchBar() {
2588
+ var bar = document.getElementById('chatSearchBar');
2589
+ if (bar) bar.remove();
2590
+ }
2591
+
2592
+ // ---------------------------------------------------------------------------
2593
+ // Navegación
2594
+ // ---------------------------------------------------------------------------
2595
+ els.btnBack.addEventListener('click', goBack);
2596
+ els.home.addEventListener('click', function (e) {
2597
+ if (e.target.closest('.newbtn')) openModal();
2598
+ });
2599
+
2600
+ // ---------------------------------------------------------------------------
2601
+ // Menú de opciones de tarjeta (⋮): por ahora solo Eliminar.
2602
+ // ---------------------------------------------------------------------------
2603
+ function openCardMenu(btn) {
2604
+ var card = btn.closest('.card');
2605
+ if (!card) return;
2606
+ var sid = parseInt(card.getAttribute('data-sid'), 10);
2607
+ els.cardMenu.innerHTML = '<div class="item danger" data-del="' + sid + '">🗑 Eliminar</div>';
2608
+ els.cardMenu.classList.add('open');
2609
+ var r = btn.getBoundingClientRect();
2610
+ var mw = els.cardMenu.offsetWidth || 170;
2611
+ var mh = els.cardMenu.offsetHeight || 46;
2612
+ var left = Math.max(8, Math.min(r.right - mw, window.innerWidth - mw - 8));
2613
+ var top = r.bottom + 6;
2614
+ if (top + mh > window.innerHeight - 8) top = Math.max(8, r.top - mh - 6);
2615
+ els.cardMenu.style.left = left + 'px';
2616
+ els.cardMenu.style.top = top + 'px';
2617
+ }
2618
+
2619
+ function closeCardMenu() {
2620
+ els.cardMenu.classList.remove('open');
2621
+ }
2622
+
2623
+ els.home.addEventListener('click', function (e) {
2624
+ var k = e.target.closest('.kebab');
2625
+ if (!k) return;
2626
+ e.stopPropagation();
2627
+ var wasOpen = els.cardMenu.classList.contains('open');
2628
+ closeCardMenu();
2629
+ if (!wasOpen) openCardMenu(k);
2630
+ });
2631
+
2632
+ // Grupos de proyectos en el home: contraer/expandir y abrir historial.
2633
+ els.home.addEventListener('click', function (e) {
2634
+ var head = e.target.closest('.pshead');
2635
+ if (head) {
2636
+ e.stopPropagation();
2637
+ var sec = head.closest('.projsec');
2638
+ if (!sec) return;
2639
+ var folder = sec.getAttribute('data-folder');
2640
+ var nowOpen = sec.classList.toggle('open');
2641
+ var ch = head.querySelector('.chev');
2642
+ if (ch) ch.textContent = nowOpen ? '▾' : '▸';
2643
+ setProjectOpen(folder, nowOpen);
2644
+ return;
2645
+ }
2646
+ var hist = e.target.closest('.histbtn');
2647
+ if (hist) {
2648
+ e.stopPropagation();
2649
+ openHistory(hist.getAttribute('data-folder'));
2650
+ }
2651
+ });
2652
+
2653
+ els.cardMenu.addEventListener('click', async function (e) {
2654
+ var it = e.target.closest('.item');
2655
+ closeCardMenu();
2656
+ if (!it) return;
2657
+ var sid = parseInt(it.getAttribute('data-del'), 10);
2658
+ if (!sid) return;
2659
+ if (!confirm('¿Borrar este chat y su historial?')) return;
2660
+ var data = await api('api.php?action=session_delete', apiCsrf('POST', { id: sid }));
2661
+ if (data.ok) {
2662
+ toast('chat eliminado', 'ok');
2663
+ loadSessions();
2664
+ } else {
2665
+ toast(data.error || 'no se pudo eliminar', 'error');
2666
+ }
2667
+ });
2668
+
2669
+ els.home.addEventListener('scroll', closeCardMenu, { passive: true });
2670
+ window.addEventListener('resize', closeCardMenu);
2671
+
2672
+ // ---------------------------------------------------------------------------
2673
+ // Vista: Archivos
2674
+ // ---------------------------------------------------------------------------
2675
+ function fmtSize(n) {
2676
+ if (n === null || n === undefined) return '';
2677
+ if (n < 1024) return n + ' B';
2678
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
2679
+ if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB';
2680
+ return (n / (1024 * 1024 * 1024)).toFixed(1) + ' GB';
2681
+ }
2682
+
2683
+ function sleepMs(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
2684
+
2685
+ // Lista/lee archivos vía la cola del puente (modo remoto): encola fs_list o
2686
+ // fs_read y consulta el resultado hasta que el puente lo resuelve.
2687
+ async function runFsCommand(cmd, arg) {
2688
+ var enq = await api('api.php?action=run_oc', apiCsrf('POST', { cmd: cmd, args: [arg || ''] }));
2689
+ if (!enq.ok) return { ok: false, error: enq.error || 'no se pudo encolar' };
2690
+ for (var i = 0; i < 25; i++) {
2691
+ await sleepMs(1200);
2692
+ var st = await api('api.php?action=oc_command_status&id=' + enq.id);
2693
+ if (st.ok && (st.status === 'done' || st.status === 'error')) {
2694
+ if (st.status === 'error') return { ok: false, error: st.error || 'error del puente' };
2695
+ try { return { ok: true, data: JSON.parse(st.result) }; }
2696
+ catch (e) { return { ok: false, error: 'respuesta inválida del puente' }; }
2697
+ }
2698
+ }
2699
+ return { ok: false, error: 'el puente tardó demasiado (¿está encendido?)' };
2700
+ }
2701
+
2702
+ // En local lee directo del disco (rápido); en remoto va por el puente.
2703
+ async function loadFiles(path, force) {
2704
+ state.fileView = null;
2705
+ state.filesPath = path || '';
2706
+ var cacheKey = state.filesPath;
2707
+ var cached = !force && state.filesCache[cacheKey];
2708
+ if (cached) {
2709
+ state.filesEntries = state.filesCache[cacheKey];
2710
+ renderFilesView();
2711
+ return;
2712
+ }
2713
+ renderFilesLoading();
2714
+ if (isLocalHost()) {
2715
+ var url = 'api.php?action=browse';
2716
+ if (state.filesPath) url += '&path=' + encodeURIComponent(state.filesPath);
2717
+ var data = await api(url);
2718
+ if (data.ok) {
2719
+ state.filesEntries = data.entries || [];
2720
+ state.filesWorkspace = data.workspace;
2721
+ state.filesCache[cacheKey] = state.filesEntries;
2722
+ renderFilesView();
2723
+ return;
2724
+ }
2725
+ // sin lectura directa: probamos por el puente
2726
+ }
2727
+ renderFilesLoading();
2728
+ var res = await runFsCommand('fs_list', state.filesPath);
2729
+ if (res.ok) {
2730
+ state.filesEntries = (res.data && res.data.entries) || [];
2731
+ state.filesCache[cacheKey] = state.filesEntries;
2732
+ renderFilesView();
2733
+ } else {
2734
+ renderFilesError(res.error || 'No se pudo listar');
2735
+ }
2736
+ }
2737
+
2738
+ function renderFilesLoading() {
2739
+ els.viewFiles.innerHTML = '<div class="view-head"><h2>archivos del proyecto</h2>'
2740
+ + '<div class="view-sub">' + esc(state.filesPath || '/') + '</div></div>'
2741
+ + '<div class="placeholder">consultando por el puente…</div>';
2742
+ }
2743
+
2744
+ function renderFilesError(msg) {
2745
+ els.viewFiles.innerHTML = '<div class="view-head"><h2>archivos del proyecto</h2>'
2746
+ + '<div class="view-sub">' + esc((state.catalog && state.catalog.workspace) || (state.filesWorkspace || 'sin workspace')) + '</div></div>'
2747
+ + '<div class="placeholder">' + esc(msg) + '</div>';
2748
+ }
2749
+
2750
+ function filesJoin(name) {
2751
+ return state.filesPath ? state.filesPath + '/' + name : name;
2752
+ }
2753
+
2754
+ function renderFilesView() {
2755
+ state.fileView = null;
2756
+ var ws = (state.catalog && state.catalog.workspace) || (state.filesWorkspace || '');
2757
+ var html = '<div class="view-head"><h2>archivos del proyecto</h2>'
2758
+ + '<div class="view-sub">' + esc(ws || 'sin workspace') + '</div></div>'
2759
+ + '<div class="files-toolbar">'
2760
+ + (state.filesPath ? '<button type="button" class="linkbtn" id="filesBack">← volver</button>' : '')
2761
+ + '<span class="files-path">' + esc(state.filesPath || '/') + '</span>'
2762
+ + '<button type="button" class="linkbtn" id="filesReload">↻ recargar</button>'
2763
+ + '</div>';
2764
+ if (!ws) {
2765
+ html += '<div class="placeholder">El puente no ha definido un workspace. Iniciá “OpenBridge” en tu PC.</div>';
2766
+ els.viewFiles.innerHTML = html;
2767
+ return;
2768
+ }
2769
+ if (!state.filesEntries.length) {
2770
+ html += '<div class="placeholder">Carpeta vacía (o solo con carpetas ignoradas).</div>';
2771
+ } else {
2772
+ html += '<div class="files-list">';
2773
+ for (var i = 0; i < state.filesEntries.length; i++) {
2774
+ var e = state.filesEntries[i];
2775
+ var isFile = e.type === 'file';
2776
+ var meta = isFile
2777
+ ? fmtSize(e.size) + (e.mtime ? ' · ' + timeShort(new Date(e.mtime * 1000).toISOString()) : '')
2778
+ : 'carpeta';
2779
+ html += '<div class="files-row" data-path="' + esc(e.name) + '" data-type="' + e.type + '">'
2780
+ + '<span class="ic">' + (isFile ? '📄' : '📁') + '</span>'
2781
+ + '<span class="name">' + esc(e.name) + '</span>'
2782
+ + '<span class="size">' + esc(meta) + '</span>'
2783
+ + '</div>';
2784
+ }
2785
+ html += '</div>';
2786
+ }
2787
+ els.viewFiles.innerHTML = html;
2788
+ var back = document.getElementById('filesBack');
2789
+ if (back) back.addEventListener('click', function () {
2790
+ var parts = state.filesPath.split('/').filter(Boolean);
2791
+ parts.pop();
2792
+ loadFiles(parts.join('/'));
2793
+ });
2794
+ var reload = document.getElementById('filesReload');
2795
+ if (reload) reload.addEventListener('click', function () {
2796
+ delete state.filesCache[state.filesPath];
2797
+ loadFiles(state.filesPath, true);
2798
+ });
2799
+ var rows = els.viewFiles.querySelectorAll('.files-row');
2800
+ for (var k = 0; k < rows.length; k++) {
2801
+ (function (row) {
2802
+ row.addEventListener('click', function () {
2803
+ var p = row.getAttribute('data-path');
2804
+ if (row.getAttribute('data-type') === 'file') openFileView(filesJoin(p));
2805
+ else loadFiles(filesJoin(p));
2806
+ });
2807
+ })(rows[k]);
2808
+ }
2809
+ }
2810
+
2811
+ // Visor read-only del contenido de un archivo (local directo, remoto por puente).
2812
+ async function openFileView(rel) {
2813
+ state.fileView = { path: rel, loading: true };
2814
+ renderFileView();
2815
+ var res;
2816
+ if (isLocalHost()) {
2817
+ var data = await api('api.php?action=read_file&path=' + encodeURIComponent(rel));
2818
+ res = data.ok
2819
+ ? { ok: true, data: { path: data.path, size: data.size, kind: data.kind || 'text', content: data.content || '', url: data.url || '' } }
2820
+ : { ok: false, error: data.error || 'error al leer' };
2821
+ } else {
2822
+ res = await runFsCommand('fs_read', rel);
2823
+ }
2824
+ if (state.fileView && state.fileView.path === rel) {
2825
+ state.fileView.loading = false;
2826
+ state.fileView.ok = !!res.ok;
2827
+ if (res.ok) state.fileView.data = res.data;
2828
+ else state.fileView.error = res.error;
2829
+ renderFileView();
2830
+ }
2831
+ }
2832
+
2833
+ function fileBaseName(p) {
2834
+ var parts = String(p || '').split('/');
2835
+ return parts[parts.length - 1] || p || '';
2836
+ }
2837
+ function fileExtOf(p) {
2838
+ var n = fileBaseName(p);
2839
+ var i = n.lastIndexOf('.');
2840
+ return i >= 0 ? n.slice(i + 1).toLowerCase() : '';
2841
+ }
2842
+
2843
+ // Líneas con número y resaltado (estilo editor VSCode, sin dependencias).
2844
+ function codeRowsHtml(content, ext) {
2845
+ var text = String(content || '').replace(/\r\n?/g, '\n');
2846
+ var lines = text.split('\n');
2847
+ if (text === '') lines = [];
2848
+ var w = String(lines.length).length;
2849
+ var html = '';
2850
+ for (var i = 0; i < lines.length; i++) {
2851
+ var no = i + 1;
2852
+ var gutter = '';
2853
+ for (var g = String(no).length; g < w; g++) gutter += ' ';
2854
+ gutter += no;
2855
+ var ln = lines[i] === '' ? ' ' : lines[i];
2856
+ html += '<div class="frow"><span class="fg">' + gutter + '</span>'
2857
+ + '<code class="fc">' + hlCode(ln, ext) + '</code></div>';
2858
+ }
2859
+ return html;
2860
+ }
2861
+
2862
+ function renderFileView() {
2863
+ var fv = state.fileView;
2864
+ var name = fileBaseName(fv.path);
2865
+ var ext = fileExtOf(fv.path);
2866
+ var isImg = /^(png|jpe?g|gif|webp|avif)$/.test(ext);
2867
+ var isMd = ext === 'md' || ext === 'markdown';
2868
+ var html = '<div class="view-head"><h2>archivo</h2>'
2869
+ + '<div class="view-sub">' + esc(fv.path) + '</div></div>'
2870
+ + '<div class="files-toolbar"><button type="button" class="linkbtn" id="fileBack">← volver a la carpeta</button></div>';
2871
+ if (fv.loading) {
2872
+ html += '<div class="placeholder">consultando por el puente…</div>';
2873
+ } else if (!fv.ok) {
2874
+ html += '<div class="placeholder">' + esc(fv.error || 'No se pudo leer') + '</div>';
2875
+ } else {
2876
+ var d = fv.data || {};
2877
+ var content = String(d.content || '');
2878
+ var sizeTxt = d.size ? fmtSize(d.size) : '';
2879
+ var hasUrl = !!d.url;
2880
+ var isImage = isImg || d.kind === 'image' || hasUrl;
2881
+ html += '<div class="fviewer"><div class="fbar">'
2882
+ + '<span class="fname">' + esc(name) + '</span>'
2883
+ + (sizeTxt ? '<span class="fsize">' + esc(sizeTxt) + '</span>' : '')
2884
+ + '<span class="fro">solo lectura</span>';
2885
+ if (!isImage && (content || ext !== '')) {
2886
+ html += '<button type="button" class="copybtn" id="fileCopy" title="Copiar contenido">⧉ copiar</button>';
2887
+ }
2888
+ html += '</div>';
2889
+ if (isImage) {
2890
+ if (hasUrl) {
2891
+ html += '<div class="fimgwrap"><img src="' + esc(d.url) + '" alt="' + esc(name) + '"></div>';
2892
+ } else {
2893
+ html += '<div class="placeholder">No se puede previsualizar la imagen por esta vía.</div>';
2894
+ }
2895
+ } else if (isMd) {
2896
+ // Markdown formateado (mismo render ligero que el chat).
2897
+ html += '<div class="fscroll"><div class="mdview">' + renderContent(content) + '</div></div>';
2898
+ } else {
2899
+ if (!content) {
2900
+ html += '<div class="placeholder">(archivo vacío)</div>';
2901
+ } else {
2902
+ html += '<div class="fscroll"><div class="fcodewrap">' + codeRowsHtml(content, ext) + '</div></div>';
2903
+ }
2904
+ }
2905
+ html += '</div>';
2906
+ }
2907
+ els.viewFiles.innerHTML = html;
2908
+ var back = document.getElementById('fileBack');
2909
+ if (back) back.addEventListener('click', function () {
2910
+ state.fileView = null;
2911
+ renderFilesView();
2912
+ });
2913
+ var copy = document.getElementById('fileCopy');
2914
+ if (copy) copy.addEventListener('click', function () {
2915
+ var txt = String((fv.data && fv.data.content) || '');
2916
+ try {
2917
+ navigator.clipboard.writeText(txt);
2918
+ copy.textContent = '✓ copiado';
2919
+ setTimeout(function () { copy.textContent = '⧉ copiar'; }, 1200);
2920
+ } catch (e) { /* sin permisos */ }
2921
+ });
2922
+ }
2923
+
2924
+ // ---------------------------------------------------------------------------
2925
+ // Vista: Sesiones opencode
2926
+ // ---------------------------------------------------------------------------
2927
+ async function loadSessionsView() {
2928
+ var data = await api('api.php?action=oc_sessions');
2929
+ state.ocSessions = (data.ok && data.sessions) || [];
2930
+ renderSessionsView();
2931
+ }
2932
+
2933
+ function renderSessionsView() {
2934
+ if (!state.ocSessions) {
2935
+ loadSessionsView();
2936
+ return;
2937
+ }
2938
+ var list = state.ocSessions;
2939
+ var html = '<div class="view-head"><h2>sesiones opencode</h2>'
2940
+ + '<div class="view-sub">' + list.length + ' sesión(es) vinculada(s)</div></div>'
2941
+ + '<div class="files-toolbar">'
2942
+ + '<button type="button" class="linkbtn" id="sessionsReload">↻ recargar</button>'
2943
+ + '</div>';
2944
+ if (!list.length) {
2945
+ html += '<div class="placeholder">Todavía no hay sesiones con opencode_session vinculado.</div>';
2946
+ } else {
2947
+ html += '<div class="cards sessions-list">';
2948
+ for (var i = 0; i < list.length; i++) {
2949
+ var s = list[i];
2950
+ html += '<div class="card" data-sid="' + s.session_id + '">'
2951
+ + '<div class="grow">'
2952
+ + '<div class="cname">' + esc(s.name) + '</div>'
2953
+ + '<div class="cmeta">' + esc(shortPath(s.folder)) + ' · ' + esc(s.model || 'sin modelo') + ' · ' + esc(s.agent) + '</div>'
2954
+ + (s.opencode_session ? '<div class="cmeta code">oc: ' + esc(s.opencode_session.slice(0, 16)) + '…</div>' : '<div class="cmeta" style="color:var(--muted)">sin opencode_session</div>')
2955
+ + '</div>'
2956
+ + '<button type="button" class="linkbtn" data-open="' + s.session_id + '" title="Abrir">abrir</button>'
2957
+ + '</div>';
2958
+ }
2959
+ html += '</div>';
2960
+ }
2961
+ els.viewSessions.innerHTML = html;
2962
+ var reload = document.getElementById('sessionsReload');
2963
+ if (reload) reload.addEventListener('click', loadSessionsView);
2964
+ var opens = els.viewSessions.querySelectorAll('[data-open]');
2965
+ for (var j = 0; j < opens.length; j++) {
2966
+ (function (btn) {
2967
+ btn.addEventListener('click', function (e) {
2968
+ e.stopPropagation();
2969
+ openChat(parseInt(btn.getAttribute('data-open'), 10));
2970
+ });
2971
+ })(opens[j]);
2972
+ }
2973
+ }
2974
+
2975
+ // ---------------------------------------------------------------------------
2976
+ // Vista: Buscar
2977
+ // ---------------------------------------------------------------------------
2978
+ var searchDebounce = null;
2979
+ function renderSearchView() {
2980
+ if (!document.getElementById('searchInput')) {
2981
+ els.viewSearch.innerHTML = '<div class="view-head"><h2>buscar en todos los chats</h2>'
2982
+ + '<div class="view-sub">2+ caracteres</div></div>'
2983
+ + '<div class="search-bar"><span>🔍</span><input type="text" id="searchInput" placeholder="escribí una palabra o frase…" autofocus></div>'
2984
+ + '<div id="searchResults"></div>';
2985
+ var input = document.getElementById('searchInput');
2986
+ input.addEventListener('input', function () {
2987
+ clearTimeout(searchDebounce);
2988
+ var q = input.value;
2989
+ searchDebounce = setTimeout(function () { runSearch(q); }, 220);
2990
+ });
2991
+ }
2992
+ runSearch((document.getElementById('searchInput') || {}).value || '');
2993
+ }
2994
+
2995
+ async function runSearch(q) {
2996
+ var out = document.getElementById('searchResults');
2997
+ if (!out) return;
2998
+ if (!q || q.length < 2) {
2999
+ out.innerHTML = '<div class="placeholder">escribí al menos 2 letras.</div>';
3000
+ return;
3001
+ }
3002
+ out.innerHTML = '<div class="placeholder">buscando…</div>';
3003
+ var data = await api('api.php?action=search_index&q=' + encodeURIComponent(q));
3004
+ if (!data.ok) {
3005
+ out.innerHTML = '<div class="placeholder">' + esc(data.error || 'error') + '</div>';
3006
+ return;
3007
+ }
3008
+ var res = data.results || [];
3009
+ if (!res.length) {
3010
+ out.innerHTML = '<div class="placeholder">sin coincidencias para “' + esc(q) + '”.</div>';
3011
+ return;
3012
+ }
3013
+ var html = '<div class="search-summary">' + res.length + ' chat(s) con coincidencias.</div>';
3014
+ for (var i = 0; i < res.length; i++) {
3015
+ var r = res[i];
3016
+ html += '<div class="search-group">'
3017
+ + '<div class="search-group-head"><b>' + esc(r.name) + '</b> · ' + r.count + ' coincidencia(s) · '
3018
+ + '<a href="#" data-sid="' + r.session_id + '" class="link">abrir chat</a></div>';
3019
+ for (var k = 0; k < r.matches.length; k++) {
3020
+ var m = r.matches[k];
3021
+ html += '<div class="search-match"><span class="role">' + esc(m.role) + '</span>' + highlightText(m.snippet, q) + '</div>';
3022
+ }
3023
+ html += '</div>';
3024
+ }
3025
+ out.innerHTML = html;
3026
+ var opens = out.querySelectorAll('[data-sid]');
3027
+ for (var j = 0; j < opens.length; j++) {
3028
+ (function (a) {
3029
+ a.addEventListener('click', function (e) {
3030
+ e.preventDefault();
3031
+ openChat(parseInt(a.getAttribute('data-sid'), 10));
3032
+ });
3033
+ })(opens[j]);
3034
+ }
3035
+ }
3036
+
3037
+ // ---------------------------------------------------------------------------
3038
+ // Vista: Vista previa del proyecto (túneles TunnelMole, corren en la PC)
3039
+ // ---------------------------------------------------------------------------
3040
+ function validPort(p) {
3041
+ var n = parseInt(p, 10);
3042
+ return (n >= 1 && n <= 65535) ? n : null;
3043
+ }
3044
+
3045
+ function renderPreviewView() {
3046
+ if (!state.previewPort) {
3047
+ try { state.previewPort = localStorage.getItem('ocx_lastPort') || ''; } catch (e) {}
3048
+ }
3049
+ els.viewPreview.innerHTML = '<div class="view-head"><h2>vista previa</h2>'
3050
+ + '<div class="view-sub">túneles del proyecto (TunnelMole, corren en tu PC)</div></div>'
3051
+ + '<div class="tunnel-form">'
3052
+ + '<input type="text" id="tunnelPort" inputmode="numeric" placeholder="puerto (ej: 8080)" value="' + esc(state.previewPort) + '" autocomplete="off">'
3053
+ + '<button type="button" class="linkbtn" id="tunnelStart">abrir túnel</button>'
3054
+ + '</div>'
3055
+ + '<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>'
3056
+ + '<div id="tunnelList"></div>';
3057
+ var portInput = document.getElementById('tunnelPort');
3058
+ portInput.addEventListener('input', function () {
3059
+ state.previewPort = portInput.value.replace(/[^0-9]/g, '');
3060
+ portInput.value = state.previewPort;
3061
+ });
3062
+ portInput.addEventListener('keydown', function (e) {
3063
+ if (e.key === 'Enter') { e.preventDefault(); startTunnel(); }
3064
+ });
3065
+ document.getElementById('tunnelStart').addEventListener('click', startTunnel);
3066
+ renderTunnels();
3067
+ refreshTunnels();
3068
+ }
3069
+
3070
+ function renderTunnels(busyMsg) {
3071
+ var box = document.getElementById('tunnelList');
3072
+ if (!box) return;
3073
+ var list = state.tunnels || [];
3074
+ var html = '';
3075
+ for (var i = 0; i < list.length; i++) {
3076
+ var t = list[i];
3077
+ var ready = !!(t.https || t.http);
3078
+ html += '<div class="tunnel">'
3079
+ + '<div class="thead"><span class="dot"' + (ready ? '' : ' style="background:var(--muted)"') + '></span>'
3080
+ + '<b>puerto ' + esc(t.port) + '</b>'
3081
+ + '<span class="tnote">' + (ready ? 'activo' : 'levantando…') + '</span>'
3082
+ + '<button type="button" class="linkbtn" data-tstop="' + esc(t.port) + '">cerrar túnel</button></div>'
3083
+ + (t.https ? '<div class="turl"><a href="' + esc(t.https) + '" target="_blank" rel="noopener noreferrer">' + esc(t.https) + '</a>'
3084
+ + '<button type="button" class="copybtn" data-tcopy="' + esc(t.https) + '" title="Copiar URL">⧉</button></div>' : '')
3085
+ + (t.http && t.http !== t.https ? '<div class="turl"><a href="' + esc(t.http) + '" target="_blank" rel="noopener noreferrer">' + esc(t.http) + '</a>'
3086
+ + '<button type="button" class="copybtn" data-tcopy="' + esc(t.http) + '" title="Copiar URL">⧉</button></div>' : '')
3087
+ + '</div>';
3088
+ }
3089
+ if (busyMsg) html += '<div class="placeholder">' + esc(busyMsg) + '</div>';
3090
+ if (!html) html = '<div class="placeholder">no hay túneles abiertos. Escribí el puerto del servidor del proyecto (ej: 8080) y abrí el túnel.</div>';
3091
+ box.innerHTML = html;
3092
+ var stops = box.querySelectorAll('[data-tstop]');
3093
+ for (var s = 0; s < stops.length; s++) {
3094
+ (function (btn) {
3095
+ btn.addEventListener('click', function () {
3096
+ stopTunnel(parseInt(btn.getAttribute('data-tstop'), 10));
3097
+ });
3098
+ })(stops[s]);
3099
+ }
3100
+ var cps = box.querySelectorAll('[data-tcopy]');
3101
+ for (var c = 0; c < cps.length; c++) {
3102
+ (function (btn) {
3103
+ btn.addEventListener('click', function () {
3104
+ copyText(btn.getAttribute('data-tcopy'), 'url copiada');
3105
+ });
3106
+ })(cps[c]);
3107
+ }
3108
+ }
3109
+
3110
+ async function startTunnel() {
3111
+ var port = validPort(state.previewPort);
3112
+ if (!port) { toast('escribí un puerto válido (1–65535)', 'error'); return; }
3113
+ try { localStorage.setItem('ocx_lastPort', String(port)); } catch (e) {}
3114
+ var already = (state.tunnels || []).some(function (t) { return parseInt(t.port, 10) === port && (t.https || t.http); });
3115
+ if (already) { toast('ya hay un túnel para el puerto ' + port, 'ok'); return; }
3116
+ renderTunnels('levantando túnel para el puerto ' + port + '… (la primera vez puede tardar si descarga tunnelmole)');
3117
+ var res = await runFsCommand('tunnel_start', String(port));
3118
+ if (!res.ok) {
3119
+ toast(res.error || 'no se pudo abrir el túnel', 'error');
3120
+ await refreshTunnels(true);
3121
+ return;
3122
+ }
3123
+ if (res.data && res.data.error) toast(res.data.error, 'error');
3124
+ await refreshTunnels(true);
3125
+ }
3126
+
3127
+ async function stopTunnel(port) {
3128
+ if (!validPort(port)) return;
3129
+ renderTunnels('cerrando túnel del puerto ' + port + '…');
3130
+ var res = await runFsCommand('tunnel_stop', String(port));
3131
+ if (!res.ok) toast(res.error || 'no se pudo cerrar el túnel', 'error');
3132
+ await refreshTunnels(true);
3133
+ }
3134
+
3135
+ async function refreshTunnels(quiet) {
3136
+ var res = await runFsCommand('tunnel_list', '');
3137
+ if (res.ok && res.data && Array.isArray(res.data.tunnels)) {
3138
+ state.tunnels = res.data.tunnels;
3139
+ } else if (!state.tunnels) {
3140
+ state.tunnels = [];
3141
+ }
3142
+ if (state.view === 'preview') renderTunnels();
3143
+ if (procState.open) renderProcTunnels();
3144
+ }
3145
+
3146
+ // ---------------------------------------------------------------------------
3147
+ // Procesos de la sesión activa (dev servers que corren en la PC vía el puente).
3148
+ // proc_start/proc_stop/proc_list/proc_log viajan por la cola del puente como
3149
+ // los túneles; el log se trae por trozos con un cursor (procState.offset).
3150
+ // ---------------------------------------------------------------------------
3151
+ function sessionProcFolder() {
3152
+ var s = state.currentSession;
3153
+ return (s && s.folder) ? s.folder : '';
3154
+ }
3155
+
3156
+ function sessionProcLabel(folder) {
3157
+ var c = state.catalog;
3158
+ if (c && Array.isArray(c.folders)) {
3159
+ for (var i = 0; i < c.folders.length; i++) {
3160
+ if (c.folders[i].path === folder) return c.folders[i].name;
3161
+ }
3162
+ }
3163
+ return shortPath(folder);
3164
+ }
3165
+
3166
+ // Encola un comando del puente y espera su resultado (hasta `tries` intentos).
3167
+ // El primer chequeo es casi inmediato (el claim suele tardar < 1 s); después
3168
+ // se va espaciando.
3169
+ async function ocCommand(cmd, args, tries, gapMs) {
3170
+ var n = tries || 20;
3171
+ var g = gapMs || 700;
3172
+ var enq = await api('api.php?action=run_oc', apiCsrf('POST', { cmd: cmd, args: args || [] }));
3173
+ if (!enq.ok) return { ok: false, error: enq.error || 'no se pudo encolar' };
3174
+ for (var i = 0; i < n; i++) {
3175
+ await sleepMs(i === 0 ? 150 : (i < 3 ? 400 : g));
3176
+ var st = await api('api.php?action=oc_command_status&id=' + enq.id);
3177
+ if (st.ok && (st.status === 'done' || st.status === 'error')) {
3178
+ if (st.status === 'error') return { ok: false, error: st.error || 'error del puente' };
3179
+ try { return { ok: true, data: JSON.parse(st.result) }; }
3180
+ catch (e) { return { ok: false, error: 'respuesta inválida del puente' }; }
3181
+ }
3182
+ }
3183
+ return { ok: false, error: 'el puente tardó demasiado (¿está encendido?)' };
3184
+ }
3185
+
3186
+ var procTimer = null;
3187
+ var procLogBox = null;
3188
+ var procLastFolder = '';
3189
+ var procPolling = false;
3190
+ var procTickCount = 0;
3191
+ var procGen = 0; // sube en cada reset/cambio de proceso: los resultados de un
3192
+ // tick en vuelo que quedó viejo se descartan (sin estado rancio)
3193
+ var PROC_POLL_MS = 2200;
3194
+ var PROC_POLL_OFFLINE_MS = 5000;
3195
+ var PROC_SUGGESTIONS = ['npm run dev', 'node server.js', 'npm start', 'npm run build'];
3196
+
3197
+ function procReset() {
3198
+ procGen++;
3199
+ procState.id = null;
3200
+ procState.offset = 0;
3201
+ procState.log = '';
3202
+ procState.logRendered = 0;
3203
+ procState.logDropped = false;
3204
+ procState.running = false;
3205
+ procState.exitCode = null;
3206
+ procState.cmd = '';
3207
+ procState.detectedPort = '';
3208
+ procState.portConflict = null;
3209
+ }
3210
+
3211
+ function stopProcsPollTimer() {
3212
+ if (procTimer) { clearTimeout(procTimer); procTimer = null; }
3213
+ }
3214
+
3215
+ function stopProcView() {
3216
+ // Sale de la vista (o cambia de sesión): corta el polling pero conserva
3217
+ // el estado (log/offset/cmd) para que al volver retome donde quedó.
3218
+ procState.open = false;
3219
+ stopProcsPollTimer();
3220
+ }
3221
+
3222
+ function enterProcsView() {
3223
+ var folder = sessionProcFolder();
3224
+ if (!folder) {
3225
+ els.viewProcs.innerHTML = '<div class="placeholder">abrí una sesión con carpeta de proyecto para ver sus procesos.</div>';
3226
+ procState.open = false;
3227
+ stopProcsPollTimer();
3228
+ return;
3229
+ }
3230
+ if (procLastFolder !== folder) {
3231
+ // Otra sesión: estado limpio (log/id/puerto). Si es la misma carpeta,
3232
+ // se conserva el log y el cursor para retomar donde quedó.
3233
+ procLastFolder = folder;
3234
+ procReset();
3235
+ }
3236
+ procState.open = true;
3237
+ renderProcView(folder);
3238
+ procPollTick();
3239
+ }
3240
+
3241
+ function renderProcView(folder) {
3242
+ els.viewProcs.innerHTML =
3243
+ '<div class="pp-head"><b>procesos</b><span class="pp-sub">' + esc(sessionProcLabel(folder)) + '</span>'
3244
+ + (state.currentId !== null ? '<button type="button" class="linkbtn pp-back" id="ppBack">← volver al chat</button>' : '')
3245
+ + '</div>'
3246
+ + '<div class="pp-status" id="procStatus"></div>'
3247
+ + '<div id="ppPortWarn"></div>'
3248
+ + '<pre id="procLog"><span class="pl-empty">sin salida todavía</span></pre>'
3249
+ + '<div class="pp-tunnel">'
3250
+ + '<div class="pp-tunhead">compartir por túnel (tunnelmole, corre en tu PC)</div>'
3251
+ + '<div class="pp-tunform"><input type="text" id="ppTunPort" inputmode="numeric" placeholder="puerto (ej: 3000)" autocomplete="off">'
3252
+ + '<button type="button" class="linkbtn" id="ppTunStart">abrir túnel</button></div>'
3253
+ + '<div id="ppTunList"></div>'
3254
+ + '</div>'
3255
+ + '<div class="pp-chips" id="procChips"></div>'
3256
+ + '<div class="pp-form"><input type="text" id="procCmdInput" placeholder="npm run dev · node server.js …" autocomplete="off">'
3257
+ + '<button type="button" class="linkbtn" id="procStartBtn">iniciar</button></div>';
3258
+ var back = document.getElementById('ppBack');
3259
+ if (back) back.addEventListener('click', function () { showView('chat'); });
3260
+ var inp = document.getElementById('procCmdInput');
3261
+ inp.value = procState.input || '';
3262
+ inp.addEventListener('input', function () { procState.input = inp.value; });
3263
+ inp.addEventListener('keydown', function (e) {
3264
+ if (e.key === 'Enter') { e.preventDefault(); startSessionProc(); }
3265
+ });
3266
+ document.getElementById('procStartBtn').addEventListener('click', startSessionProc);
3267
+ var tunPort = document.getElementById('ppTunPort');
3268
+ tunPort.value = procState.detectedPort || '';
3269
+ tunPort.addEventListener('input', function () {
3270
+ tunPort.value = tunPort.value.replace(/[^0-9]/g, '');
3271
+ if (tunPort.value) state.previewPort = tunPort.value;
3272
+ });
3273
+ tunPort.addEventListener('keydown', function (e) {
3274
+ if (e.key === 'Enter') { e.preventDefault(); panelStartTunnel(); }
3275
+ });
3276
+ document.getElementById('ppTunStart').addEventListener('click', panelStartTunnel);
3277
+ buildProcChips();
3278
+ procLogBox = document.getElementById('procLog');
3279
+ renderProcStatus();
3280
+ renderPortWarn();
3281
+ renderProcTunnels();
3282
+ refreshTunnels(true);
3283
+ }
3284
+
3285
+ // Aviso "puerto en uso" cuando un dev server falla con EADDRINUSE, con la
3286
+ // opción de liberarlo (pregunta antes de matar al proceso que lo agarra).
3287
+ function renderPortWarn() {
3288
+ var box = document.getElementById('ppPortWarn');
3289
+ if (!box) return;
3290
+ if (!procState.portConflict) { box.innerHTML = ''; return; }
3291
+ var p = procState.portConflict;
3292
+ box.innerHTML = '<div class="pp-warn"><span>⚠ el puerto <b>' + esc(p) + '</b> está en uso'
3293
+ + ' (¿un dev server viejo?)</span>'
3294
+ + '<button type="button" class="linkbtn" id="ppFreePort">liberar puerto ' + esc(p) + '</button></div>';
3295
+ document.getElementById('ppFreePort').addEventListener('click', freePortConflict);
3296
+ }
3297
+
3298
+ async function freePortConflict() {
3299
+ var p = procState.portConflict;
3300
+ if (!p) return;
3301
+ if (!confirm('¿Libero el puerto ' + p + '? Mata el proceso que lo está usando en tu PC.')) return;
3302
+ var btn = document.getElementById('ppFreePort');
3303
+ if (btn) btn.disabled = true;
3304
+ var res = await ocCommand('port_free', [String(p)], 30, 800);
3305
+ if (btn) btn.disabled = false;
3306
+ if (!res.ok) { toast(res.error || 'no se pudo liberar el puerto', 'error'); return; }
3307
+ if (res.data && res.data.freed) {
3308
+ toast('puerto ' + p + ' liberado · toqué iniciar para reintentar', 'ok');
3309
+ procState.portConflict = null;
3310
+ renderPortWarn();
3311
+ } else {
3312
+ toast('nadie está escuchando el puerto ' + p + ' ahora; reintentá iniciar', 'ok');
3313
+ procState.portConflict = null;
3314
+ renderPortWarn();
3315
+ }
3316
+ }
3317
+
3318
+ function buildProcChips() {
3319
+ var box = document.getElementById('procChips');
3320
+ if (!box) return;
3321
+ var html = '';
3322
+ for (var i = 0; i < PROC_SUGGESTIONS.length; i++) {
3323
+ html += '<button type="button" class="chip" data-cmd="' + esc(PROC_SUGGESTIONS[i]) + '">' + esc(PROC_SUGGESTIONS[i]) + '</button>';
3324
+ }
3325
+ box.innerHTML = html;
3326
+ var chips = box.querySelectorAll('.chip');
3327
+ for (var c = 0; c < chips.length; c++) {
3328
+ (function (btn) {
3329
+ btn.addEventListener('click', function () {
3330
+ procState.input = btn.getAttribute('data-cmd');
3331
+ var inp = document.getElementById('procCmdInput');
3332
+ if (inp) inp.value = procState.input;
3333
+ startSessionProc();
3334
+ });
3335
+ })(chips[c]);
3336
+ }
3337
+ }
3338
+
3339
+ function renderProcStatus(extra) {
3340
+ var box = document.getElementById('procStatus');
3341
+ if (!box) return;
3342
+ var startBtn = document.getElementById('procStartBtn');
3343
+ var html = '';
3344
+ var on = bridgeOnline();
3345
+ if (extra) {
3346
+ html = '<span class="dot"></span><span class="pp-cmd">' + esc(extra) + '</span>';
3347
+ } else if (!on) {
3348
+ html = '<span class="dot off"></span><span class="pp-cmd">puente apagado: no se pueden iniciar procesos</span>';
3349
+ } else if (procState.running) {
3350
+ html = '<span class="dot on"></span><span class="pp-cmd">' + esc(procState.cmd || 'proceso en marcha') + '</span>'
3351
+ + '<button type="button" class="linkbtn" id="procStopBtn">detener</button>';
3352
+ } else if (procState.exitCode !== null) {
3353
+ html = '<span class="dot off"></span><span class="pp-cmd">' + esc(procState.cmd || '')
3354
+ + (procState.exitCode === 0 ? ' · terminó' : ' · terminó con código ' + procState.exitCode) + '</span>'
3355
+ + (procState.exitErr ? ' <span style="color:var(--danger)">' + esc(procState.exitErr) + '</span>' : '');
3356
+ } else {
3357
+ html = '<span class="dot"></span><span class="pp-cmd">sin proceso en esta sesión · iniciá uno abajo</span>';
3358
+ }
3359
+ box.innerHTML = html;
3360
+ var stopBtn = document.getElementById('procStopBtn');
3361
+ if (stopBtn) stopBtn.addEventListener('click', stopSessionProc);
3362
+ if (startBtn) startBtn.disabled = !on;
3363
+ var input = document.getElementById('procCmdInput');
3364
+ if (input) input.disabled = !on;
3365
+ }
3366
+
3367
+ function pushProcLog(text) {
3368
+ if (!text) return;
3369
+ var full = procState.log + text;
3370
+ if (full.length > 280000) {
3371
+ full = full.slice(-280000);
3372
+ procState.logDropped = true;
3373
+ }
3374
+ procState.log = full;
3375
+ }
3376
+
3377
+ function flushProcLog() {
3378
+ if (!procLogBox) return;
3379
+ var wasBottom = procLogBox.scrollHeight - procLogBox.scrollTop - procLogBox.clientHeight < 48;
3380
+ if (procState.logDropped) {
3381
+ procLogBox.textContent = procState.log;
3382
+ procState.logDropped = false;
3383
+ } else if (procState.log.length > procState.logRendered) {
3384
+ if (procState.logRendered === 0) procLogBox.textContent = ''; // quita el placeholder
3385
+ var span = document.createElement('span');
3386
+ span.textContent = procState.log.slice(procState.logRendered);
3387
+ procLogBox.appendChild(span);
3388
+ }
3389
+ procState.logRendered = procState.log.length;
3390
+ if (wasBottom) procLogBox.scrollTop = procLogBox.scrollHeight;
3391
+ }
3392
+
3393
+ function scheduleProcPoll() {
3394
+ stopProcsPollTimer();
3395
+ if (!procState.open) return;
3396
+ var delay = bridgeOnline() ? PROC_POLL_MS : PROC_POLL_OFFLINE_MS;
3397
+ procTimer = setTimeout(procPollTick, delay);
3398
+ }
3399
+
3400
+ async function procPollTick() {
3401
+ if (!procState.open || procPolling) return; // un solo loop de polling
3402
+ procPolling = true;
3403
+ var myGen = procGen;
3404
+ try {
3405
+ var folder = sessionProcFolder();
3406
+ if (!folder) {
3407
+ // Se fue la carpeta (sesión cerrada): mensaje y fuera.
3408
+ els.viewProcs.innerHTML = '<div class="placeholder">abrí una sesión con carpeta de proyecto para ver sus procesos.</div>';
3409
+ procState.open = false;
3410
+ stopProcsPollTimer();
3411
+ return;
3412
+ }
3413
+ if (!bridgeOnline()) {
3414
+ renderProcStatus();
3415
+ scheduleProcPoll();
3416
+ return;
3417
+ }
3418
+ // "consultando…" solo hasta que sepamos el estado: después el estado
3419
+ // real persiste entre ticks (si no, el cartel parpadea en cada ciclo).
3420
+ if (!procState.id && !procState.running && procState.exitCode === null) {
3421
+ renderProcStatus('consultando…');
3422
+ }
3423
+ // proc_list solo cuando falta el proceso o ya terminó (para detectar
3424
+ // reinicios). Si corre, el proc_log ya trae running/done/exitCode:
3425
+ // la mitad de los comandos por ciclo.
3426
+ var needList = !procState.id || !procState.running;
3427
+ var list = needList ? await ocCommand('proc_list', [], 40, 600) : { ok: true, skipped: true };
3428
+ if (myGen !== procGen) return; // el estado cambó mientras volaba: descartar
3429
+ var knowNothing = !procState.id && !procState.running && procState.exitCode === null;
3430
+ if (list.ok && !list.skipped) {
3431
+ var mine = null;
3432
+ if (list.data && Array.isArray(list.data.procs)) {
3433
+ var cand = [];
3434
+ for (var i = 0; i < list.data.procs.length; i++) {
3435
+ if (list.data.procs[i].folder === folder) cand.push(list.data.procs[i]);
3436
+ }
3437
+ // Preferimos el proceso corriente; si no hay, el más reciente.
3438
+ for (var k = 0; k < cand.length; k++) {
3439
+ if (cand[k].running) { mine = cand[k]; break; }
3440
+ if (!mine || cand[k].id > mine.id) mine = cand[k];
3441
+ }
3442
+ }
3443
+ if (mine && mine.id !== procState.id) {
3444
+ procReset();
3445
+ procState.id = mine.id;
3446
+ }
3447
+ if (mine) {
3448
+ procState.running = !!mine.running;
3449
+ procState.exitCode = mine.exitCode;
3450
+ if (mine.cmd) procState.cmd = mine.cmd;
3451
+ procState.exitErr = mine.exitErr || '';
3452
+ } else {
3453
+ // Sin proceso en esta carpeta (o el puente lo olvidó al reiniciar):
3454
+ // estado limpio, no arrastrar el botón "detener" de un proceso muerto.
3455
+ if (procState.id || procState.running) {
3456
+ // Veníamos de seguir un proceso que ya no existe: casi seguro
3457
+ // el puente se reinició (los procesos mueren con él).
3458
+ toast('el puente se reinició: los procesos quedaron cortados', 'ok');
3459
+ }
3460
+ procState.running = false;
3461
+ procState.exitCode = null;
3462
+ procState.id = null;
3463
+ procState.log = '';
3464
+ procState.offset = 0;
3465
+ procState.cmd = '';
3466
+ }
3467
+ knowNothing = !procState.id && !procState.running && procState.exitCode === null;
3468
+ }
3469
+ if (procState.id) {
3470
+ var res = await ocCommand('proc_log', [String(procState.id), String(procState.offset)], 40, 600);
3471
+ if (myGen !== procGen) return; // idem: el reset de un inicio/parada ganó
3472
+ if (res.ok && res.data) {
3473
+ pushProcLog(res.data.text);
3474
+ procState.offset = (res.data.offset || 0);
3475
+ if (res.data.spawnError) procState.exitErr = res.data.spawnError;
3476
+ if (res.data.done) {
3477
+ procState.running = false;
3478
+ procState.exitCode = res.data.exitCode;
3479
+ }
3480
+ // Puerto del dev server detectado en la salida (para el túnel).
3481
+ var pm = String(res.data.text || '').match(/(?:localhost|127\.0\.0\.1):\s*(\d{1,5})/i);
3482
+ if (pm && !procState.detectedPort) {
3483
+ procState.detectedPort = pm[1];
3484
+ var tp = document.getElementById('ppTunPort');
3485
+ if (tp && !tp.value) tp.value = pm[1];
3486
+ }
3487
+ // Puerto en uso (dev server viejo): ofrecer liberarlo.
3488
+ if (!procState.portConflict) {
3489
+ var em = String(res.data.text || '').match(/EADDRINUSE[^0-9]*([0-9]{1,5})/i)
3490
+ || String(res.data.text || '').match(/address already in use[^0-9]*([0-9]{1,5})/i);
3491
+ if (em) procState.portConflict = parseInt(em[1], 10);
3492
+ }
3493
+ }
3494
+ }
3495
+ // Túneles: refresco liviano cada 3 ciclos (cada uno es un comando más).
3496
+ procTickCount++;
3497
+ if (procState.open && procTickCount % 3 === 0) {
3498
+ await refreshTunnels(true);
3499
+ }
3500
+ if (procState.open && myGen === procGen) {
3501
+ // Con la cola caída o lenta: conservar el último estado real en vez
3502
+ // de pintar "sin proceso" (que escondería el botón "detener").
3503
+ if (list.ok || !knowNothing) renderProcStatus();
3504
+ renderPortWarn();
3505
+ flushProcLog();
3506
+ }
3507
+ } catch (e) {
3508
+ // nunca deja de pollear
3509
+ } finally {
3510
+ procPolling = false;
3511
+ scheduleProcPoll();
3512
+ }
3513
+ }
3514
+
3515
+ async function startSessionProc() {
3516
+ var folder = sessionProcFolder();
3517
+ if (!folder) return;
3518
+ var cmd = (procState.input || '').trim();
3519
+ if (!cmd) { toast('escribí un comando (ej: npm run dev)', 'error'); return; }
3520
+ renderProcStatus('iniciando: ' + cmd + '…');
3521
+ var res = await ocCommand('proc_start', [folder, cmd], 45, 700);
3522
+ if (!res.ok) {
3523
+ if ((res.error || '').indexOf('tardó demasiado') >= 0) {
3524
+ // El puente está procesando un mensaje: el comando quedó encolado
3525
+ // y va a arrancar cuando se libere. El polling lo adopta solo.
3526
+ toast('el puente está ocupado; el proceso va a arrancar en cuanto se libere', 'ok');
3527
+ renderProcStatus('esperando al puente (ocupado)…');
3528
+ return;
3529
+ }
3530
+ toast(res.error || 'no se pudo iniciar el proceso', 'error');
3531
+ renderProcStatus();
3532
+ return;
3533
+ }
3534
+ procReset();
3535
+ procState.id = res.data && res.data.id ? res.data.id : null;
3536
+ procState.running = true;
3537
+ procState.cmd = cmd;
3538
+ procState.input = cmd;
3539
+ if (!procState.id) { toast('no se recibió el id del proceso', 'error'); }
3540
+ await procPollTick();
3541
+ }
3542
+
3543
+ async function stopSessionProc() {
3544
+ if (!procState.id) return;
3545
+ renderProcStatus('deteniendo…');
3546
+ var res = await ocCommand('proc_stop', [String(procState.id)], 30, 700);
3547
+ procGen++;
3548
+ if (!res.ok) toast(res.error || 'no se pudo detener el proceso', 'error');
3549
+ await procPollTick();
3550
+ }
3551
+
3552
+ function renderProcTunnels(busyMsg) {
3553
+ var box = document.getElementById('ppTunList');
3554
+ if (!box) return;
3555
+ var list = state.tunnels || [];
3556
+ var html = '';
3557
+ for (var i = 0; i < list.length; i++) {
3558
+ var t = list[i];
3559
+ var ready = !!(t.https || t.http);
3560
+ html += '<div class="tunnel"><div class="thead"><span class="dot"' + (ready ? '' : ' style="background:var(--muted)"') + '></span>'
3561
+ + '<b>puerto ' + esc(t.port) + '</b><span class="tnote">' + (ready ? 'activo' : 'levantando…') + '</span>'
3562
+ + '<button type="button" class="linkbtn" data-ptstop="' + esc(t.port) + '">cerrar</button></div>'
3563
+ + (t.https ? '<div class="turl"><a href="' + esc(t.https) + '" target="_blank" rel="noopener noreferrer">' + esc(t.https) + '</a>'
3564
+ + '<button type="button" class="copybtn" data-ptcopy="' + esc(t.https) + '" title="Copiar URL">⧉</button></div>' : '')
3565
+ + (t.http && t.http !== t.https ? '<div class="turl"><a href="' + esc(t.http) + '" target="_blank" rel="noopener noreferrer">' + esc(t.http) + '</a>'
3566
+ + '<button type="button" class="copybtn" data-ptcopy="' + esc(t.http) + '" title="Copiar URL">⧉</button></div>' : '')
3567
+ + '</div>';
3568
+ }
3569
+ if (busyMsg) html += '<div class="pp-msg" style="padding:4px 0 0">' + esc(busyMsg) + '</div>';
3570
+ if (!html) html = '<div class="pp-msg" style="padding:4px 0 0">sin túneles abiertos</div>';
3571
+ box.innerHTML = html;
3572
+ var stops = box.querySelectorAll('[data-ptstop]');
3573
+ for (var s = 0; s < stops.length; s++) {
3574
+ (function (btn) {
3575
+ btn.addEventListener('click', function () {
3576
+ panelStopTunnel(parseInt(btn.getAttribute('data-ptstop'), 10));
3577
+ });
3578
+ })(stops[s]);
3579
+ }
3580
+ var cps = box.querySelectorAll('[data-ptcopy]');
3581
+ for (var c = 0; c < cps.length; c++) {
3582
+ (function (btn) {
3583
+ btn.addEventListener('click', function () {
3584
+ copyText(btn.getAttribute('data-ptcopy'), 'url copiada');
3585
+ });
3586
+ })(cps[c]);
3587
+ }
3588
+ }
3589
+
3590
+ // Túnel desde el panel de la sesión (misma cola del puente que los procesos).
3591
+ async function panelStartTunnel() {
3592
+ var inp = document.getElementById('ppTunPort');
3593
+ var port = validPort(inp ? inp.value : '');
3594
+ if (!port) { toast('escribí un puerto válido (1–65535)', 'error'); return; }
3595
+ try { localStorage.setItem('ocx_lastPort', String(port)); } catch (e) {}
3596
+ var already = (state.tunnels || []).some(function (t) { return parseInt(t.port, 10) === port; });
3597
+ if (already) { toast('ya hay un túnel para el puerto ' + port, 'ok'); return; }
3598
+ renderProcTunnels('levantando túnel para el puerto ' + port + '… (la primera vez descarga tunnelmole)');
3599
+ // Tope generoso: con el fallback de npx la primera vez descarga el paquete.
3600
+ var res = await ocCommand('tunnel_start', [String(port)], 90, 800);
3601
+ if (!res.ok) toast(res.error || 'no se pudo abrir el túnel', 'error');
3602
+ await refreshTunnels(true);
3603
+ renderProcTunnels();
3604
+ }
3605
+
3606
+ async function panelStopTunnel(port) {
3607
+ if (!validPort(port)) return;
3608
+ renderProcTunnels('cerrando túnel del puerto ' + port + '…');
3609
+ var res = await ocCommand('tunnel_stop', [String(port)], 30, 700);
3610
+ if (!res.ok) toast(res.error || 'no se pudo cerrar el túnel', 'error');
3611
+ await refreshTunnels(true);
3612
+ renderProcTunnels();
3613
+ }
3614
+
3615
+ // ---------------------------------------------------------------------------
3616
+ // SSE: stream persistente con fallback a polling.
3617
+ // ---------------------------------------------------------------------------
3618
+ function scheduleRefresh(reason) {
3619
+ var now = Date.now();
3620
+ if (state.refreshPending) return;
3621
+ var elapsed = now - (state.lastRefresh || 0);
3622
+ var wait = elapsed < 600 ? (600 - elapsed) : 0;
3623
+ state.refreshPending = true;
3624
+ setTimeout(function () {
3625
+ state.refreshPending = false;
3626
+ state.lastRefresh = Date.now();
3627
+ refreshActiveView(reason);
3628
+ }, wait);
3629
+ }
3630
+
3631
+ function sseDisabled() {
3632
+ if (typeof CFG.sseDisabled !== 'undefined') return !!CFG.sseDisabled;
3633
+ try {
3634
+ var h = location.hostname || '';
3635
+ if (h === '127.0.0.1' || h === 'localhost') return true;
3636
+ var m = /[?&]sse=0(?:&|$)/.test(location.search || '');
3637
+ if (m) return true;
3638
+ } catch (e) {}
3639
+ return false;
3640
+ }
3641
+
3642
+ function startSSE() {
3643
+ if (state.sseBroken) { startPollingFallback(); return; }
3644
+ if (state.sse) { try { state.sse.close(); } catch (e) {} }
3645
+ if (sseDisabled()) {
3646
+ try { console.log('SSE desactivado (local), usando polling'); } catch (e) {}
3647
+ startPollingFallback();
3648
+ return;
3649
+ }
3650
+ if (!('EventSource' in window)) { startPollingFallback(); return; }
3651
+ var es = new EventSource('api.php?action=stream&bridge=' + encodeURIComponent(activeBridgeId()));
3652
+ state.sse = es;
3653
+ state.sseGotHello = false;
3654
+ // Si el hosting bufferea la respuesta, 'hello' no llega a tiempo:
3655
+ // cortamos y pasamos a polling para no quedarnos colgados.
3656
+ clearTimeout(state.sseHelloTimer);
3657
+ state.sseHelloTimer = setTimeout(function () {
3658
+ if (!state.sseGotHello) {
3659
+ state.sseBroken = true;
3660
+ try { es.close(); } catch (e2) {}
3661
+ state.sse = null;
3662
+ try { console.log('SSE sin respuesta (hosting lo bufferea), usando polling'); } catch (e3) {}
3663
+ startPollingFallback();
3664
+ }
3665
+ }, 6000);
3666
+ es.addEventListener('hello', function () {
3667
+ state.sseGotHello = true;
3668
+ clearTimeout(state.sseHelloTimer);
3669
+ state.sseBackoff = 1000;
3670
+ });
3671
+ es.addEventListener('online', function (e) {
3672
+ // Latido de los puentes: evento liviano con su resumen (selector).
3673
+ try {
3674
+ var d = JSON.parse(e.data);
3675
+ if (Array.isArray(d.bridges)) applyBridges(d.bridges);
3676
+ if (d.ts && state.catalog) {
3677
+ var info = activeBridgeInfo();
3678
+ if (info && info.online) state.catalog.last_online_ts = d.ts;
3679
+ }
3680
+ applyOnlineUI();
3681
+ } catch (err) { /* ignore */ }
3682
+ });
3683
+ es.addEventListener('catalog', function (e) {
3684
+ // Cambió el catálogo de un puente: solo interesa si es el activo.
3685
+ try {
3686
+ var d = JSON.parse(e.data);
3687
+ if (typeof d.bridge !== 'string') return;
3688
+ if (d.bridge === activeBridgeId()) refreshActiveCatalog();
3689
+ } catch (err) { /* ignore */ }
3690
+ });
3691
+ es.addEventListener('sessions_changed', function () {
3692
+ if (state.view === 'home') scheduleRefresh('sessions_changed');
3693
+ else if (state.view === 'chat') scheduleRefresh('sessions_changed');
3694
+ else if (state.view === 'history') scheduleRefresh('sessions_changed');
3695
+ else if (state.view === 'sessions' && typeof loadSessionsView === 'function') loadSessionsView();
3696
+ });
3697
+ es.addEventListener('ping', function () { /* keep-alive */ });
3698
+ es.addEventListener('bye', function () {
3699
+ state.sseBackoff = 1000;
3700
+ });
3701
+ es.onerror = function () {
3702
+ clearTimeout(state.sseHelloTimer);
3703
+ if (state.sseBroken) return;
3704
+ try { es.close(); } catch (e) {}
3705
+ state.sse = null;
3706
+ setTimeout(function () { startSSE(); }, state.sseBackoff);
3707
+ state.sseBackoff = Math.min(state.sseBackoff * 2, 15000);
3708
+ };
3709
+ }
3710
+
3711
+ // Polling (fallback cuando no hay SSE o el hosting lo bufferea).
3712
+ // Adaptativo: sondeo liviano del catálogo (con ?v= responde mínimo si no
3713
+ // cambió) y refresco rápido del chat solo mientras hay algo esperando.
3714
+ function chatWaiting() {
3715
+ if (state.view !== 'chat' || !state.messages.length) return false;
3716
+ // Miramos los últimos 3: mientras el usuario esté pendiente O el borrador
3717
+ // del asistente siga en streaming hay que refrescar rápido (si no, la
3718
+ // respuesta final nunca llega y la franja KITT queda pegada).
3719
+ var n = state.messages.length;
3720
+ for (var i = n - 1; i >= Math.max(0, n - 3); i--) {
3721
+ var st = state.messages[i].status || 'done';
3722
+ if (st === 'pending' || st === 'processing' || st === 'streaming') return true;
3723
+ }
3724
+ return false;
3725
+ }
3726
+
3727
+ function startPollingFallback() {
3728
+ if (state.pollTimer) return;
3729
+ var tick = async function () {
3730
+ var wait = POLL_BASE_MS;
3731
+ try {
3732
+ var data = await api('api.php?action=catalog&v=' + encodeURIComponent(state.catVer || ''));
3733
+ if (data.ok && applyCatalogPayload(data)) scheduleRefresh('poll');
3734
+ } catch (e) { /* reintenta en el próximo ciclo */ }
3735
+ if (state.view === 'chat') {
3736
+ if (chatWaiting()) {
3737
+ scheduleRefresh('poll-chat');
3738
+ wait = POLL_FAST_MS;
3739
+ }
3740
+ } else if (state.view === 'home') {
3741
+ scheduleRefresh('poll-sessions');
3742
+ }
3743
+ state.pollTimer = setTimeout(tick, wait);
3744
+ };
3745
+ tick();
3746
+ }
3747
+
3748
+ function refreshActiveView(reason) {
3749
+ if (state.view === 'home' && typeof loadSessions === 'function') loadSessions();
3750
+ else if (state.view === 'chat') {
3751
+ if (state.currentId !== null && typeof loadHistory === 'function') loadHistory(state.currentId, true);
3752
+ // El sidebar necesita previews frescas (punto "esperando respuesta").
3753
+ if (typeof loadSessions === 'function') loadSessions();
3754
+ }
3755
+ else if (state.view === 'files' && typeof loadFiles === 'function') loadFiles(state.filesPath);
3756
+ else if (state.view === 'sessions' && typeof loadSessionsView === 'function') loadSessionsView();
3757
+ else if (state.view === 'history' && typeof renderHistory === 'function') renderHistory();
3758
+ // search: solo refresca si hay query activa (lo dispara el debounce del input)
3759
+ }
3760
+
3761
+ // ---------------------------------------------------------------------------
3762
+ // Boot
3763
+ // ---------------------------------------------------------------------------
3764
+ // Arranque en dos fases: pinta al instante con la caché local y refresca con
3765
+ // un único llamado a bootstrap (catálogo condicionado por versión + sesiones).
3766
+ async function boot() {
3767
+ var cachedCat = lsGet(catalogKey());
3768
+ var cachedVer = lsGet(catalogVerKey());
3769
+ var cachedSessions = lsGet('ocx_sessions');
3770
+ var painted = false;
3771
+ if (cachedCat && typeof cachedCat === 'object') {
3772
+ state.catalog = cachedCat;
3773
+ state.catVer = typeof cachedVer === 'string' ? cachedVer : '';
3774
+ fillSelects();
3775
+ applyOnlineUI();
3776
+ }
3777
+ if (Array.isArray(cachedSessions) && cachedSessions.length) {
3778
+ renderHome(cachedSessions);
3779
+ painted = true;
3780
+ }
3781
+ renderBridgeBar();
3782
+ var data = await api('api.php?action=bootstrap&v=' + encodeURIComponent(state.catVer || ''));
3783
+ if (data.ok) {
3784
+ applyBootPayload(data);
3785
+ } else if (!painted) {
3786
+ els.home.innerHTML = '<div class="empty">Sin conexión</div>';
3787
+ }
3788
+ }
3789
+
3790
+ // ---------------------------------------------------------------------------
3791
+ // Avisos push (Web Push nativo de la PWA).
3792
+ // Suscripción del dispositivo al push service; el servidor guarda el endpoint
3793
+ // y avisa cuando la IA termina de responder.
3794
+ // ---------------------------------------------------------------------------
3795
+ function pushSupported() {
3796
+ return !!(CFG.pushEnabled && CFG.pushKey
3797
+ && 'Notification' in window && 'PushManager' in window
3798
+ && 'serviceWorker' in navigator && window.isSecureContext);
3799
+ }
3800
+
3801
+ function b64urlToBytes(b64) {
3802
+ var t = String(b64).replace(/-/g, '+').replace(/_/g, '/');
3803
+ while (t.length % 4) t += '=';
3804
+ var bin = atob(t);
3805
+ var out = new Uint8Array(bin.length);
3806
+ for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
3807
+ return out;
3808
+ }
3809
+
3810
+ function bytesToB64url(buf) {
3811
+ var bytes = new Uint8Array(buf);
3812
+ var bin = '';
3813
+ for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
3814
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
3815
+ }
3816
+
3817
+ function pushSetBtn(on, denied) {
3818
+ if (!els.btnPush) return;
3819
+ els.btnPush.classList.remove('on', 'denied');
3820
+ if (denied) els.btnPush.classList.add('denied');
3821
+ else if (on) els.btnPush.classList.add('on');
3822
+ els.btnPush.setAttribute('aria-pressed', on ? 'true' : 'false');
3823
+ els.btnPush.title = on ? 'Avisos activados · tocar para desactivar'
3824
+ : (denied ? 'Avisos bloqueados por el navegador' : 'Activar avisos en este dispositivo');
3825
+ }
3826
+
3827
+ async function pushReport(sub) {
3828
+ var p256dh = sub.getKey ? sub.getKey('p256dh') : null;
3829
+ var auth = sub.getKey ? sub.getKey('auth') : null;
3830
+ if (!p256dh || !auth) return false;
3831
+ var res = await api('api.php?action=push_subscribe', apiCsrf('POST', {
3832
+ endpoint: sub.endpoint,
3833
+ p256dh: bytesToB64url(p256dh),
3834
+ auth: bytesToB64url(auth),
3835
+ ua: (navigator.userAgent || '').slice(0, 120)
3836
+ }));
3837
+ return !!(res && res.ok);
3838
+ }
3839
+
3840
+ async function pushSubscribe() {
3841
+ var reg = await navigator.serviceWorker.ready;
3842
+ var sub = await reg.pushManager.subscribe({
3843
+ userVisibleOnly: true,
3844
+ applicationServerKey: b64urlToBytes(CFG.pushKey)
3845
+ });
3846
+ return await pushReport(sub);
3847
+ }
3848
+
3849
+ async function pushDisable() {
3850
+ var reg = await navigator.serviceWorker.ready;
3851
+ var sub = await reg.pushManager.getSubscription();
3852
+ if (sub) {
3853
+ api('api.php?action=push_unsubscribe', apiCsrf('POST', { endpoint: sub.endpoint }));
3854
+ try { await sub.unsubscribe(); } catch (e) {}
3855
+ }
3856
+ pushSetBtn(false, false);
3857
+ toast('Avisos desactivados en este dispositivo');
3858
+ }
3859
+
3860
+ async function pushSync() {
3861
+ if (!pushSupported()) return;
3862
+ els.btnPush.style.display = 'inline-flex';
3863
+ if (Notification.permission !== 'granted') {
3864
+ pushSetBtn(false, Notification.permission === 'denied');
3865
+ return;
3866
+ }
3867
+ var sub = null;
3868
+ try {
3869
+ var reg = await navigator.serviceWorker.ready;
3870
+ sub = await reg.pushManager.getSubscription();
3871
+ } catch (e) { pushSetBtn(false, false); return; }
3872
+ if (!sub) {
3873
+ try {
3874
+ var ok = await pushSubscribe();
3875
+ pushSetBtn(ok, false);
3876
+ } catch (e) { pushSetBtn(false, false); }
3877
+ return;
3878
+ }
3879
+ // Ya existe la suscripción: se re-reporta (idempotente) por si el servidor la perdió.
3880
+ var ok = await pushReport(sub);
3881
+ pushSetBtn(ok, false);
3882
+ }
3883
+
3884
+ function initPush() {
3885
+ if (!pushSupported()) return;
3886
+ els.btnPush.addEventListener('click', async function () {
3887
+ if (Notification.permission === 'denied') {
3888
+ toast('Los avisos están bloqueados · permitilos en los ajustes del navegador', 'error');
3889
+ return;
3890
+ }
3891
+ // Pedir el permiso al inicio del handler conserva el gesto del usuario.
3892
+ if (Notification.permission !== 'granted') {
3893
+ var granted = (await Notification.requestPermission()) === 'granted';
3894
+ if (!granted) {
3895
+ pushSetBtn(false, Notification.permission === 'denied');
3896
+ toast('Necesitás permitir las notificaciones para recibir avisos', 'error');
3897
+ return;
3898
+ }
3899
+ }
3900
+ var sub = null;
3901
+ try {
3902
+ var reg = await navigator.serviceWorker.ready;
3903
+ sub = await reg.pushManager.getSubscription();
3904
+ } catch (e) {}
3905
+ if (sub) {
3906
+ pushDisable();
3907
+ return;
3908
+ }
3909
+ try {
3910
+ var ok = await pushSubscribe();
3911
+ if (ok) {
3912
+ pushSetBtn(true, false);
3913
+ toast('Avisos activados · te avisamos cuando la IA responda');
3914
+ } else {
3915
+ toast('No se pudieron registrar los avisos', 'error');
3916
+ }
3917
+ } catch (e) {
3918
+ toast('No se pudieron activar los avisos', 'error');
3919
+ }
3920
+ });
3921
+ navigator.serviceWorker.register('sw.js', { updateViaCache: 'none' }).catch(function () {}).then(function () {
3922
+ return pushSync();
3923
+ });
3924
+ }
3925
+
3926
+ applyTheme(state.theme, { persist: false });
3927
+ bindChatSearch();
3928
+ els.messages.addEventListener('scroll', onMsgScroll, { passive: true });
3929
+ autoGrow();
3930
+ showView('home');
3931
+ boot();
3932
+ startSSE();
3933
+ if (INITIAL_SESSION !== null) openChat(INITIAL_SESSION);
3934
+
3935
+ if ('serviceWorker' in navigator && location.protocol === 'https:') {
3936
+ navigator.serviceWorker.register('sw.js', { updateViaCache: 'none' }).catch(function () {});
3937
+ }
3938
+
3939
+ if (pushSupported()) initPush();