@danieltmn/tamnora 0.24.1 → 0.24.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.24.2] - 2026-10-30
9
+
10
+ ### Cambios
11
+
12
+ - feat(consola): el input del chat crece hacia arriba al escribir
13
+ - feat: botón para volver al chat desde las demás páginas
14
+
8
15
  ## [0.24.1] - 2026-10-28
9
16
 
10
17
  ### Cambios
@@ -220,6 +220,8 @@ document.addEventListener('keydown', (event) => {
220
220
  if (event.key === 'Escape' && !moreMenu.hidden) hideMoreMenu();
221
221
  });
222
222
 
223
+ $('#back-chat')?.addEventListener('click', () => activateTab('chat'));
224
+
223
225
  if (settingsModal) {
224
226
  $('#settings-toggle')?.addEventListener('click', openSettingsModal);
225
227
  $('#settings-close')?.addEventListener('click', closeSettingsModal);
@@ -103,6 +103,16 @@ export const panesEl = $('#panes');
103
103
  export const tabbarEl = $('#tabbar');
104
104
  export const chatForm = $('#chat-form');
105
105
  export const chatInput = $('#chat-input');
106
+
107
+ /**
108
+ * Ajusta el alto del textarea al contenido: crece hacia arriba a medida que se
109
+ * escribe, hasta el `max-height` del CSS (donde pasa a scrollear por dentro).
110
+ * Hay que llamarlo tambien al vaciarlo por codigo, o quedaria alto y vacio.
111
+ */
112
+ export function autoGrowChatInput() {
113
+ chatInput.style.height = 'auto';
114
+ chatInput.style.height = `${chatInput.scrollHeight}px`;
115
+ }
106
116
  export const submitBtn = chatForm.querySelector('button[type="submit"]');
107
117
  export const stopBtn = $('#stop-btn');
108
118
  export const interruptBtn = $('#interrupt-btn');
@@ -305,6 +315,7 @@ export async function regenerate(message) {
305
315
  await api(`/api/sessions/${id}/truncate`, { method: 'POST', body: { fromId: lastUser.id } }).catch(() => {});
306
316
  await reloadPane(id);
307
317
  chatInput.value = userText(lastUser.content);
318
+ autoGrowChatInput();
308
319
  await sendMessage();
309
320
  }
310
321
 
@@ -338,6 +349,7 @@ export async function undoTurn(message) {
338
349
 
339
350
  export function editMessage(message) { const id = messageId(message);
340
351
  chatInput.value = message.textEl.textContent;
352
+ autoGrowChatInput();
341
353
  chatInput.focus();
342
354
  state.pendingEdit = id ? { fromId: id } : null;
343
355
  }
@@ -753,6 +765,7 @@ export function addContinueButton(turn, data) {
753
765
  btn.addEventListener('click', () => {
754
766
  if (state.panes.get(turn.sessionKey)?.working) return;
755
767
  chatInput.value = 'Continuá donde quedaste.';
768
+ autoGrowChatInput();
756
769
  sendMessage();
757
770
  });
758
771
  turn.assistant().tools.appendChild(btn);
@@ -1195,6 +1208,7 @@ export async function sendMessage() {
1195
1208
  chatInput.value = '';
1196
1209
  state.attachments = [];
1197
1210
  renderAttachments();
1211
+ autoGrowChatInput();
1198
1212
  if (command) runShell(command);
1199
1213
  return;
1200
1214
  }
@@ -1224,6 +1238,7 @@ export async function sendMessage() {
1224
1238
  chatInput.value = '';
1225
1239
  state.attachments = [];
1226
1240
  renderAttachments();
1241
+ autoGrowChatInput();
1227
1242
  syncInterruptBtn();
1228
1243
  return;
1229
1244
  }
@@ -1240,6 +1255,7 @@ export async function sendMessage() {
1240
1255
  chatInput.value = '';
1241
1256
  state.attachments = [];
1242
1257
  renderAttachments();
1258
+ autoGrowChatInput();
1243
1259
  await startTurn(state.sessionId, activePane, input, images);
1244
1260
  }
1245
1261
 
@@ -1275,6 +1291,7 @@ export function sendInterrupt() {
1275
1291
  chatInput.value = '';
1276
1292
  state.attachments = [];
1277
1293
  renderAttachments();
1294
+ autoGrowChatInput();
1278
1295
  syncInterruptBtn();
1279
1296
  interruptAndSend(state.sessionId, input, images);
1280
1297
  }
@@ -44,7 +44,8 @@ export function setCwd(value) {
44
44
 
45
45
  export const moreTab = $('#more-tab');
46
46
  export const moreMenu = $('#more-menu');
47
- export const topbarSection = $('#topbar-section');
47
+ export const topbarSection = $('#topbar-section');
48
+ export const backChat = $('#back-chat');
48
49
 
49
50
  export const TAB_LABELS = { chat: 'chat', providers: 'proveedores', ajustes: 'ajustes', eval: 'eval', runs: 'runs' };
50
51
 
@@ -61,6 +62,7 @@ export function activateTab(name) {
61
62
  .querySelectorAll('#more-menu button[data-tab]')
62
63
  .forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
63
64
  if (topbarSection) topbarSection.textContent = TAB_LABELS[name] || name;
65
+ if (backChat) backChat.hidden = name === 'chat';
64
66
  moreTab.classList.toggle('active', name !== 'chat');
65
67
  document.querySelectorAll('.view').forEach((v) => v.classList.toggle('active', v.id === `view-${name}`));
66
68
  hideMoreMenu();
@@ -3,6 +3,7 @@ import {
3
3
  activePane,
4
4
  addCompactCard,
5
5
  addMessage,
6
+ autoGrowChatInput,
6
7
  basename,
7
8
  chatInput,
8
9
  finishCompactCard,
@@ -398,6 +399,7 @@ export function handleSlashKey(event) {
398
399
  export function onChatInputChanged() {
399
400
  slashIndex = 0;
400
401
  updateSlashMenu();
402
+ autoGrowChatInput();
401
403
  syncInterruptBtn();
402
404
  }
403
405
 
@@ -75,7 +75,7 @@
75
75
  </head>
76
76
  <body>
77
77
  <header class="topbar">
78
- <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.24.1</span></div>
78
+ <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <button id="back-chat" type="button" class="back-chat" title="volver al chat" aria-label="volver al chat" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6" /></svg><span>chat</span></button> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.24.2</span></div>
79
79
  <div class="settings">
80
80
  <button id="new-session-top" type="button" class="btn topbar-new" title="nueva sesión" aria-label="nueva sesión">
81
81
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
@@ -571,6 +571,27 @@ html[data-scheme='light'] .brand .logo img { content: url('/icons/logo_light.png
571
571
  letter-spacing: 0.08em;
572
572
  white-space: nowrap;
573
573
  }
574
+ /* Botón para volver al chat desde las demás páginas (proveedores, ajustes, eval, runs). */
575
+ .back-chat {
576
+ display: inline-flex;
577
+ align-items: center;
578
+ gap: 3px;
579
+ height: 24px;
580
+ padding: 0 9px 0 5px;
581
+ border: 1px solid var(--border);
582
+ border-radius: 999px;
583
+ background: var(--theirs);
584
+ color: var(--muted);
585
+ cursor: pointer;
586
+ font: 700 10px var(--sans);
587
+ text-transform: uppercase;
588
+ letter-spacing: 0.08em;
589
+ white-space: nowrap;
590
+ transition: 0.15s ease;
591
+ }
592
+ .back-chat[hidden] { display: none; }
593
+ .back-chat:hover { border-color: var(--accent); color: var(--accent); }
594
+ .back-chat svg { width: 15px; height: 15px; display: block; flex: 0 0 auto; }
574
595
  .menu { position: relative; display: block; }
575
596
 
576
597
  /* El icono muestra el tema al que se va a cambiar: claro -> luna, oscuro -> sol. */
@@ -1,495 +1,495 @@
1
- import { livePermission, reloadPane, renderSessions, resolvePermissionCard, sendPresence } from './chat.js';
2
- import { providerSelect, setSummaryText, yesInput } from './chrome.js';
3
- import { toast } from './commands.js';
4
- import { $, api, clear, el, openStream, showConfirm, state, storage, streamPost } from './core.js';
5
- import { diffState, localState, pollDelayMs } from './reconcile.js';
6
- import { followRun } from './runStream.js';
7
- import { closeTab, renderTabs, showStandalonePermission } from './sessions.js';
8
-
9
- /* --- eval ---------------------------------------------------------------- */
10
-
11
- export const evalTasks = $('#eval-tasks');
12
- export const evalResults = $('#eval-results');
13
- export const evalSummary = $('#eval-summary');
14
- export const evalRunBtn = $('#eval-run');
15
-
16
- export function parseTasks(text) {
17
- const trimmed = text.trim();
18
- if (!trimmed) return [];
19
- try {
20
- const parsed = JSON.parse(trimmed);
21
- return Array.isArray(parsed) ? parsed : [parsed];
22
- } catch {
23
- // fall through to JSONL
24
- }
25
- return trimmed
26
- .split('\n')
27
- .map((l) => l.trim())
28
- .filter(Boolean)
29
- .map((l) => JSON.parse(l));
30
- }
31
-
32
- export function setEvalSummary(text, cls = '') {
33
- setSummaryText(evalSummary, text, cls);
34
- }
35
-
36
- export function detailOf(entry) {
37
- if (entry.error) return entry.error;
38
- return (entry.trace?.scorers ?? []).map((s) => `${s.scorer}:${s.passed ? 'ok' : 'no'}`).join(' ');
39
- }
40
-
41
- export function addResultRow(tbody, entry) {
42
- const tr = el('tr');
43
- const pass = entry.passed;
44
- tr.append(
45
- el('td', pass ? 'pass' : 'fail', pass ? '✓' : '·'),
46
- el('td', '', entry.taskId ?? '—'),
47
- el('td', '', entry.model ?? '—'),
48
- el('td', '', entry.score === undefined || entry.score === null ? '' : String(entry.score)),
49
- el('td', '', entry.durationMs === undefined ? '' : String(entry.durationMs)),
50
- el('td', 'detail', detailOf(entry))
51
- );
52
- tbody.appendChild(tr);
53
- }
54
-
55
- export async function runEval() {
56
- let tasks;
57
- try {
58
- tasks = parseTasks(evalTasks.value);
59
- } catch (error) {
60
- return setEvalSummary(`Dataset inválido: ${error.message}`, 'err');
61
- }
62
- const models = $('#eval-models')
63
- .value.split(',')
64
- .map((s) => s.trim())
65
- .filter(Boolean);
66
- if (!tasks.length) return setEvalSummary('Ingresá al menos una tarea.', 'err');
67
- if (!models.length) return setEvalSummary('Ingresá al menos un modelo.', 'err');
68
-
69
- clear(evalResults);
70
- evalRunBtn.disabled = true;
71
- setEvalSummary('corriendo…');
72
- try {
73
- await streamPost(
74
- '/api/eval',
75
- {
76
- tasks,
77
- models,
78
- provider: providerSelect.value || undefined,
79
- trials: Number($('#eval-trials').value) || 1,
80
- concurrency: Number($('#eval-concurrency').value) || 1,
81
- judgeModel: $('#eval-judge').value.trim() || undefined,
82
- yes: yesInput.checked ? true : undefined,
83
- },
84
- (event, data) => {
85
- if (event === 'run') setEvalSummary(`Run ${data.runId} · ${data.total} ejecuciones`);
86
- else if (event === 'result') addResultRow(evalResults, data);
87
- else if (event === 'summary') {
88
- const s = data.summary;
89
- setEvalSummary(`total ${s.total} · ${s.passed} ok · ${s.errors} errores · ${(s.durationMs / 1000).toFixed(1)}s`);
90
- } else if (event === 'error') setEvalSummary(`Error: ${data.message}`, 'err');
91
- }
92
- );
93
- } catch (error) {
94
- setEvalSummary(`Error: ${error.message}`, 'err');
95
- } finally {
96
- evalRunBtn.disabled = false;
97
- }
98
- }
99
-
100
- /* --- runs ---------------------------------------------------------------- */
101
-
102
- export const runsList = $('#runs-list');
103
- export const runDetail = $('#run-detail');
104
-
105
- export async function refreshRuns() {
106
- const data = await api('/api/runs').catch(() => ({ runs: [] }));
107
- clear(runsList);
108
- clear(runDetail);
109
- for (const run of data.runs) {
110
- const li = el('li');
111
- li.append(el('span', '', run.dataset || '(inline)'), el('span', 'muted', `${run.status} · ${new Date(run.created_at).toLocaleString()}`));
112
- li.addEventListener('click', () => {
113
- runsList.querySelectorAll('li').forEach((n) => n.classList.remove('active'));
114
- li.classList.add('active');
115
- openRun(run.id);
116
- });
117
- runsList.appendChild(li);
118
- }
119
- if (!data.runs.length) runsList.appendChild(el('li', 'muted', 'Sin runs.'));
120
- }
121
-
122
- export async function openRun(id) {
123
- const data = await api(`/api/runs/${id}`);
124
- clear(runDetail);
125
- const summary = data.run.summary ?? {};
126
- runDetail.appendChild(el('p', 'muted', `total ${summary.total ?? 0} · ${summary.passed ?? 0} ok · ${summary.errors ?? 0} errores`));
127
- const table = el('table', 'results');
128
- const thead = el('thead');
129
- const head = el('tr');
130
- for (const label of ['ok', 'tarea', 'modelo', 'score', 'ms', 'detalle']) head.appendChild(el('th', '', label));
131
- thead.appendChild(head);
132
- const tbody = el('tbody');
133
- table.append(thead, tbody);
134
- runDetail.appendChild(table);
135
- for (const row of data.results) {
136
- addResultRow(tbody, {
137
- taskId: row.task_id,
138
- model: row.model,
139
- passed: Boolean(row.passed),
140
- score: row.score,
141
- durationMs: row.duration_ms,
142
- trace: row.trace,
143
- });
144
- }
145
- }
146
-
147
- /* --- reconciliacion: la verdad no depende del canal ------------------------ */
148
-
149
- // El canal global lleva el heartbeat de 15 s del server, asi que 30 s son tres
150
- // pings: menos espera cuando lo que se pierde tiempo es que aparezca un permiso
151
- // que te esta esperando.
152
- export const CHANNEL_SILENCE_MS = 30000;
153
-
154
- export let lastState = null;
155
- export let reconciling = false;
156
- export let reconcileTimer = null;
157
- export let syncStale = false;
158
-
159
- /**
160
- * Un solo lugar decide que hacer con el estado remoto.
161
- *
162
- * Antes esto estaba repartido entre el canal global y un sweep: dos caminos que
163
- * se contradijian, lo que hacia que un SSE caido se tradujera en "no me entero
164
- * de nada". La logica de diff vive en `reconcile.js`, que es puro y testeado.
165
- *
166
- * La verdad es `/api/state`: un request corto. Los SSE son atajos de latencia.
167
- */
168
- export async function reconcile({ immediate = false } = {}) {
169
- if (reconciling) {
170
- // Si hay una en curso, encadenamos otra: si no, una mutacion que cae justo
171
- // en medio se pierde.
172
- if (immediate) state.reconcileAgain = true;
173
- return;
174
- }
175
- reconciling = true;
176
- try {
177
- const remote = await api('/api/state');
178
- syncStale = false;
179
- renderSyncState();
180
- applyState(diffState(localState({ panes: state.panes, asks: state.pendingAsks.keys() }), remote));
181
- lastState = remote;
182
- scheduleReconcile();
183
- } catch (error) {
184
- // Esto antes era un `.catch(() => null)` silencioso: un fallo de red se via
185
- // exactamente igual que "no hay nada pendiente", y por eso no se podia
186
- // diagnosticar nada.
187
- syncStale = true;
188
- renderSyncState(error);
189
- scheduleReconcile();
190
- } finally {
191
- reconciling = false;
192
- if (state.reconcileAgain) {
193
- state.reconcileAgain = false;
194
- reconcile();
195
- }
196
- }
197
- }
198
-
199
- export function scheduleReconcile() {
200
- if (reconcileTimer) clearTimeout(reconcileTimer);
201
- // Con un permiso pendiente hay algo esperandonos: la latencia se nota.
202
- reconcileTimer = setTimeout(() => reconcile(), pollDelayMs(lastState));
203
- }
204
-
205
- export function applyState(diff) {
206
- for (const id of diff.gone) closeTab(id);
207
- for (const id of diff.toDetach) reloadPane(id);
208
- for (const id of diff.toAttach) reloadPane(id);
209
- for (const askId of diff.resolvedAsks) {
210
- state.pendingAsks.delete(askId);
211
- // `null` = alguien lo resolvio en otro lado: la tarjeta ya se cerro alla y
212
- // solo hay que apagarla.
213
- resolvePermissionCard(state.permissionCards.get(askId), null);
214
- }
215
- for (const ask of diff.newAsks) {
216
- if (state.pendingAsks.has(ask.askId)) continue;
217
- state.pendingAsks.set(ask.askId, ask);
218
- toast(`Permiso pendiente: ${ask.name || 'una tool'}${state.panes.has(ask.sessionId) ? '' : ' en otra sesión'}.`);
219
- }
220
- // Los permisos pendientes se pintan siempre, sin importar por donde llegaron:
221
- // asi tambien se ven cuando el canal estaba caido y llego por el snapshot.
222
- for (const [askId, ask] of state.pendingAsks) {
223
- const record = state.panes.get(ask.sessionId);
224
- if (record && !livePermission(askId)) showStandalonePermission(record, ask);
225
- }
226
- state.remoteActive = new Set(diff.remoteActive);
227
- renderSessions(diff.sessions);
228
- renderTabs();
229
- renderAlertSignals();
230
- }
231
-
232
- /* --- señales de alerta: que se note aun mirando otra cosa ----------------- */
233
-
234
- export const FAVICON_IDLE = '/favicon.ico';
235
- export const FAVICON_SOURCE = '/icons/icon-192x192.png';
236
- export let faviconAlert = '';
237
- export let faviconAlertPending = null;
238
-
239
- /**
240
- * El favicon de alerta se dibuja sobre una copia del logo (mismo origen), para
241
- * que el punto rojo combine con el icono real y no con un SVG desincronizado.
242
- */
243
- export function alertFavicon() {
244
- if (faviconAlert) return Promise.resolve(faviconAlert);
245
- if (faviconAlertPending) return faviconAlertPending;
246
- faviconAlertPending = new Promise((resolve) => {
247
- const img = new Image();
248
- img.onload = () => {
249
- try {
250
- const canvas = document.createElement('canvas');
251
- canvas.width = 64;
252
- canvas.height = 64;
253
- const ctx = canvas.getContext('2d');
254
- ctx.drawImage(img, 0, 0, 64, 64);
255
- ctx.beginPath();
256
- ctx.arc(64 - 16, 16, 13, 0, Math.PI * 2);
257
- ctx.fillStyle = '#d9704a';
258
- ctx.fill();
259
- ctx.lineWidth = 3;
260
- ctx.strokeStyle = 'rgba(35,26,23,0.9)';
261
- ctx.stroke();
262
- faviconAlert = canvas.toDataURL('image/png');
263
- resolve(faviconAlert);
264
- } catch {
265
- resolve(FAVICON_IDLE);
266
- }
267
- };
268
- img.onerror = () => resolve(FAVICON_IDLE);
269
- img.src = FAVICON_SOURCE;
270
- });
271
- return faviconAlertPending;
272
- }
273
-
274
- export let baseTitle = '';
275
-
276
- /**
277
- * Cuando iOS manda la pestana a background mata los timers y los SSE. El
278
- * titulo y el favicon son las **unicas** señales que quedan, asi que los dos
279
- * tienen que marcar que hay un permiso esperando.
280
- */
281
- export function renderAlertSignals() {
282
- const pending = state.pendingAsks.size + state.pendingQuestions.size;
283
- if (!baseTitle) baseTitle = document.title;
284
- const clean = baseTitle.replace(/^\(⚠ \d+\) /, '');
285
- document.title = pending > 0 ? `(⚠ ${pending}) ${clean}` : clean;
286
- const link = document.querySelector('link[rel="icon"]');
287
- if (link) {
288
- if (pending > 0) alertFavicon().then((href) => { link.href = href; });
289
- else link.href = FAVICON_IDLE;
290
- }
291
- }
292
-
293
- /* --- canal global: solo latencia ------------------------------------------ */
294
-
295
- /**
296
- * Un SSE por pestana para enterarse al instante. **No decide nada**: dispara
297
- * `reconcile()`. Si se cae -que es lo normal en un tunel, y lo que hace iOS al
298
- * mandar la pestana a background- solo perdemos inmediatez, no correctitud.
299
- */
300
- export function handleGlobalEvent(event) {
301
- // No decide nada: solo pide el estado fresco. El diff es el unico que sabe
302
- // que hay que hacer.
303
- if (event === 'run_start' || event === 'run_end' || event === 'permission' || event === 'permission_resolved' || event === 'snapshot') {
304
- reconcile({ immediate: true });
305
- }
306
- }
307
-
308
- export let eventsController = null;
309
- export let channelState = 'off';
310
- export let channelReason = '';
311
-
312
- export function renderChannelState() {
313
- const node = $('#sb-channel');
314
- if (!node) return;
315
- if (channelState === 'live') {
316
- node.hidden = true;
317
- return;
318
- }
319
- node.hidden = false;
320
- node.className = `sb-channel ${channelState}`;
321
- node.textContent = channelState === 'off' ? 'sin canal' : 'reconectando…';
322
- node.title = channelReason
323
- ? `Canal de eventos caido (${channelReason}). Los cambios llegan igual por sondeo cada ${Math.round(pollDelayMs(lastState) / 1000)} s, pero con retardo.`
324
- : 'Canal de eventos: sin novedades instantaneas.';
325
- }
326
-
327
- /** El sondeo de /api/state tambien puede caerse; eso si hay que verlo. */
328
- export function renderSyncState(error) {
329
- const node = $('#sb-sync');
330
- if (!node) return;
331
- node.hidden = !syncStale;
332
- node.className = 'sb-channel retrying';
333
- node.textContent = 'sin sincronía';
334
- node.title = error
335
- ? `No pude leer el estado del server: ${error.message}. Reintentando cada ${Math.round(pollDelayMs(lastState) / 1000)} s.`
336
- : 'No pude leer el estado del server. Reintentando.';
337
- }
338
-
339
- export async function followEvents() {
340
- if (eventsController && !eventsController.signal.aborted) return;
341
- const controller = new AbortController();
342
- eventsController = controller;
343
- channelState = 'waiting';
344
- channelReason = '';
345
- renderChannelState();
346
- await followRun({
347
- signal: controller.signal,
348
- attempts: Infinity,
349
- backoff: [1000, 2000, 5000, 10000, 15000],
350
- onRetry: ({ attempt, error }) => {
351
- channelState = 'retrying';
352
- channelReason = error?.message ? `${error.message} (intento ${attempt})` : `sin datos (intento ${attempt})`;
353
- renderChannelState();
354
- },
355
- open: (cursor, signal) =>
356
- openStream('/api/events', { signal, silenceMs: CHANNEL_SILENCE_MS }, (event, data) => {
357
- channelState = 'live';
358
- channelReason = '';
359
- renderChannelState();
360
- handleGlobalEvent(event, data);
361
- }),
362
- })
363
- .catch((error) => {
364
- // Un AbortError es que estamos cerrando. Cualquier otra cosa es un bug de
365
- // la consola: se avisa en vez de dejar un "reconectando" que no dice nada.
366
- if (error?.name === 'AbortError') return;
367
- channelState = 'off';
368
- channelReason = error?.message ?? String(error);
369
- renderChannelState();
370
- toast(`Canal de eventos roto: ${error?.message ?? error}`);
371
- })
372
- .finally(() => {
373
- if (eventsController === controller) {
374
- channelState = 'off';
375
- renderChannelState();
376
- }
377
- });
378
- }
379
-
380
- /* --- ciclo de vida --------------------------------------------------------- */
381
-
382
- export function watchLifecycle() {
383
- // Volver a `visible` es el momento de reconciliar: iOS congela los timers en
384
- // background, asi que esperar al proximo tick puede ser Mucho tiempo.
385
- const wake = () => {
386
- reconcile({ immediate: true });
387
- sendPresence();
388
- if (!eventsController || eventsController.signal.aborted) followEvents();
389
- };
390
- document.addEventListener('visibilitychange', () => {
391
- if (document.visibilityState === 'visible') wake();
392
- });
393
- window.addEventListener('pageshow', wake);
394
- window.addEventListener('online', wake);
395
- window.addEventListener('focus', wake);
396
- // La cache del server dura 10 min: revisar al volver a la pestaña es barato.
397
- refreshUpdate();
398
- }
399
-
400
- /* --- actualizacion de version --------------------------------------------- */
401
-
402
- export const UPDATE_DISMISS_KEY = 'tamnora-update-dismissed';
403
- export const updateBanner = $('#update-banner');
404
- export const updateBannerText = $('#update-banner-text');
405
- export let updateInfo = null;
406
-
407
- export function showUpdateBanner(info, { force = false } = {}) {
408
- if (!updateBanner || !info?.updateAvailable) return;
409
- if (!force && storage.get(UPDATE_DISMISS_KEY) === String(info.latest)) return;
410
- updateInfo = info;
411
- updateBannerText.textContent = `Hay una versión nueva (v${info.latest}); tenés la v${info.current}.`;
412
- updateBanner.hidden = false;
413
- const ver = document.querySelector('.brand .ver');
414
- if (ver) {
415
- ver.classList.add('has-update');
416
- ver.title = `Actualización disponible: v${info.latest}`;
417
- }
418
- }
419
-
420
- export async function refreshUpdate() {
421
- if (!updateBanner) return;
422
- const info = await api('/api/update').catch(() => null);
423
- if (info?.updateAvailable) showUpdateBanner(info);
424
- }
425
-
426
- export function dismissUpdate() {
427
- if (updateInfo?.latest) storage.set(UPDATE_DISMISS_KEY, String(updateInfo.latest));
428
- if (updateBanner) updateBanner.hidden = true;
429
- }
430
-
431
- /** Chequeo manual (click en la version): fuerza la consulta y avisa el resultado. */
432
- export async function forceUpdateCheck() {
433
- const ver = document.querySelector('.brand .ver');
434
- const original = ver?.textContent;
435
- if (ver) ver.textContent = '…';
436
- const info = await api('/api/update?force=1').catch(() => null);
437
- if (ver) ver.textContent = info?.current ? `v${info.current}` : original;
438
- if (!info) return toast('No pude consultar la versión.');
439
- if (info.updateAvailable) {
440
- showUpdateBanner(info, { force: true });
441
- toast(`Hay una versión nueva: v${info.latest}`);
442
- } else if (info.error) {
443
- toast(`No pude consultar npm: ${info.error}`);
444
- } else {
445
- toast(`Estás al día (v${info.current}).`);
446
- }
447
- }
448
-
449
- /** Espera a que el server reiniciado vuelva a responder `/api/health`. */
450
- export async function waitServerVersion(tries = 45) {
451
- for (let i = 0; i < tries; i++) {
452
- await new Promise((r) => setTimeout(r, 2000));
453
- const health = await fetch('/api/health', { cache: 'no-store' })
454
- .then((r) => (r.ok ? r.json() : null))
455
- .catch(() => null);
456
- if (health?.version) return health.version;
457
- }
458
- return '';
459
- }
460
-
461
- export async function runUpdate() {
462
- if (!updateInfo) return;
463
- if (
464
- !(await showConfirm(`¿Actualizar a la v${updateInfo.latest}? La consola se reinicia sola.`, {
465
- title: 'Actualizar Tamnora',
466
- confirmLabel: 'actualizar',
467
- }))
468
- ) {
469
- return;
470
- }
471
- const runBtn = $('#update-run');
472
- const laterBtn = $('#update-later');
473
- if (runBtn) runBtn.disabled = true;
474
- if (laterBtn) laterBtn.disabled = true;
475
- updateBannerText.textContent = `Actualizando a v${updateInfo.latest}…`;
476
- const res = await api('/api/update', { method: 'POST' }).catch((error) => ({ error: error.message }));
477
- if (res?.error) {
478
- updateBannerText.textContent = `No se pudo actualizar: ${res.error}`;
479
- if (runBtn) runBtn.disabled = false;
480
- if (laterBtn) laterBtn.disabled = false;
481
- return;
482
- }
483
- updateBannerText.textContent = 'Instalando la versión nueva… la consola se va a reconectar.';
484
- const version = await waitServerVersion();
485
- if (version) {
486
- updateBannerText.textContent = `Actualizado a v${version}. Recargando…`;
487
- setTimeout(() => location.reload(), 900);
488
- } else {
489
- updateBannerText.textContent = 'No pude confirmar el reinicio. Recargá la página en unos segundos.';
490
- if (runBtn) runBtn.disabled = false;
491
- if (laterBtn) laterBtn.disabled = false;
492
- }
493
- }
494
-
495
- export const brandVerEl = document.querySelector('.brand .ver');
1
+ import { livePermission, reloadPane, renderSessions, resolvePermissionCard, sendPresence } from './chat.js';
2
+ import { providerSelect, setSummaryText, yesInput } from './chrome.js';
3
+ import { toast } from './commands.js';
4
+ import { $, api, clear, el, openStream, showConfirm, state, storage, streamPost } from './core.js';
5
+ import { diffState, localState, pollDelayMs } from './reconcile.js';
6
+ import { followRun } from './runStream.js';
7
+ import { closeTab, renderTabs, showStandalonePermission } from './sessions.js';
8
+
9
+ /* --- eval ---------------------------------------------------------------- */
10
+
11
+ export const evalTasks = $('#eval-tasks');
12
+ export const evalResults = $('#eval-results');
13
+ export const evalSummary = $('#eval-summary');
14
+ export const evalRunBtn = $('#eval-run');
15
+
16
+ export function parseTasks(text) {
17
+ const trimmed = text.trim();
18
+ if (!trimmed) return [];
19
+ try {
20
+ const parsed = JSON.parse(trimmed);
21
+ return Array.isArray(parsed) ? parsed : [parsed];
22
+ } catch {
23
+ // fall through to JSONL
24
+ }
25
+ return trimmed
26
+ .split('\n')
27
+ .map((l) => l.trim())
28
+ .filter(Boolean)
29
+ .map((l) => JSON.parse(l));
30
+ }
31
+
32
+ export function setEvalSummary(text, cls = '') {
33
+ setSummaryText(evalSummary, text, cls);
34
+ }
35
+
36
+ export function detailOf(entry) {
37
+ if (entry.error) return entry.error;
38
+ return (entry.trace?.scorers ?? []).map((s) => `${s.scorer}:${s.passed ? 'ok' : 'no'}`).join(' ');
39
+ }
40
+
41
+ export function addResultRow(tbody, entry) {
42
+ const tr = el('tr');
43
+ const pass = entry.passed;
44
+ tr.append(
45
+ el('td', pass ? 'pass' : 'fail', pass ? '✓' : '·'),
46
+ el('td', '', entry.taskId ?? '—'),
47
+ el('td', '', entry.model ?? '—'),
48
+ el('td', '', entry.score === undefined || entry.score === null ? '' : String(entry.score)),
49
+ el('td', '', entry.durationMs === undefined ? '' : String(entry.durationMs)),
50
+ el('td', 'detail', detailOf(entry))
51
+ );
52
+ tbody.appendChild(tr);
53
+ }
54
+
55
+ export async function runEval() {
56
+ let tasks;
57
+ try {
58
+ tasks = parseTasks(evalTasks.value);
59
+ } catch (error) {
60
+ return setEvalSummary(`Dataset inválido: ${error.message}`, 'err');
61
+ }
62
+ const models = $('#eval-models')
63
+ .value.split(',')
64
+ .map((s) => s.trim())
65
+ .filter(Boolean);
66
+ if (!tasks.length) return setEvalSummary('Ingresá al menos una tarea.', 'err');
67
+ if (!models.length) return setEvalSummary('Ingresá al menos un modelo.', 'err');
68
+
69
+ clear(evalResults);
70
+ evalRunBtn.disabled = true;
71
+ setEvalSummary('corriendo…');
72
+ try {
73
+ await streamPost(
74
+ '/api/eval',
75
+ {
76
+ tasks,
77
+ models,
78
+ provider: providerSelect.value || undefined,
79
+ trials: Number($('#eval-trials').value) || 1,
80
+ concurrency: Number($('#eval-concurrency').value) || 1,
81
+ judgeModel: $('#eval-judge').value.trim() || undefined,
82
+ yes: yesInput.checked ? true : undefined,
83
+ },
84
+ (event, data) => {
85
+ if (event === 'run') setEvalSummary(`Run ${data.runId} · ${data.total} ejecuciones`);
86
+ else if (event === 'result') addResultRow(evalResults, data);
87
+ else if (event === 'summary') {
88
+ const s = data.summary;
89
+ setEvalSummary(`total ${s.total} · ${s.passed} ok · ${s.errors} errores · ${(s.durationMs / 1000).toFixed(1)}s`);
90
+ } else if (event === 'error') setEvalSummary(`Error: ${data.message}`, 'err');
91
+ }
92
+ );
93
+ } catch (error) {
94
+ setEvalSummary(`Error: ${error.message}`, 'err');
95
+ } finally {
96
+ evalRunBtn.disabled = false;
97
+ }
98
+ }
99
+
100
+ /* --- runs ---------------------------------------------------------------- */
101
+
102
+ export const runsList = $('#runs-list');
103
+ export const runDetail = $('#run-detail');
104
+
105
+ export async function refreshRuns() {
106
+ const data = await api('/api/runs').catch(() => ({ runs: [] }));
107
+ clear(runsList);
108
+ clear(runDetail);
109
+ for (const run of data.runs) {
110
+ const li = el('li');
111
+ li.append(el('span', '', run.dataset || '(inline)'), el('span', 'muted', `${run.status} · ${new Date(run.created_at).toLocaleString()}`));
112
+ li.addEventListener('click', () => {
113
+ runsList.querySelectorAll('li').forEach((n) => n.classList.remove('active'));
114
+ li.classList.add('active');
115
+ openRun(run.id);
116
+ });
117
+ runsList.appendChild(li);
118
+ }
119
+ if (!data.runs.length) runsList.appendChild(el('li', 'muted', 'Sin runs.'));
120
+ }
121
+
122
+ export async function openRun(id) {
123
+ const data = await api(`/api/runs/${id}`);
124
+ clear(runDetail);
125
+ const summary = data.run.summary ?? {};
126
+ runDetail.appendChild(el('p', 'muted', `total ${summary.total ?? 0} · ${summary.passed ?? 0} ok · ${summary.errors ?? 0} errores`));
127
+ const table = el('table', 'results');
128
+ const thead = el('thead');
129
+ const head = el('tr');
130
+ for (const label of ['ok', 'tarea', 'modelo', 'score', 'ms', 'detalle']) head.appendChild(el('th', '', label));
131
+ thead.appendChild(head);
132
+ const tbody = el('tbody');
133
+ table.append(thead, tbody);
134
+ runDetail.appendChild(table);
135
+ for (const row of data.results) {
136
+ addResultRow(tbody, {
137
+ taskId: row.task_id,
138
+ model: row.model,
139
+ passed: Boolean(row.passed),
140
+ score: row.score,
141
+ durationMs: row.duration_ms,
142
+ trace: row.trace,
143
+ });
144
+ }
145
+ }
146
+
147
+ /* --- reconciliacion: la verdad no depende del canal ------------------------ */
148
+
149
+ // El canal global lleva el heartbeat de 15 s del server, asi que 30 s son tres
150
+ // pings: menos espera cuando lo que se pierde tiempo es que aparezca un permiso
151
+ // que te esta esperando.
152
+ export const CHANNEL_SILENCE_MS = 30000;
153
+
154
+ export let lastState = null;
155
+ export let reconciling = false;
156
+ export let reconcileTimer = null;
157
+ export let syncStale = false;
158
+
159
+ /**
160
+ * Un solo lugar decide que hacer con el estado remoto.
161
+ *
162
+ * Antes esto estaba repartido entre el canal global y un sweep: dos caminos que
163
+ * se contradijian, lo que hacia que un SSE caido se tradujera en "no me entero
164
+ * de nada". La logica de diff vive en `reconcile.js`, que es puro y testeado.
165
+ *
166
+ * La verdad es `/api/state`: un request corto. Los SSE son atajos de latencia.
167
+ */
168
+ export async function reconcile({ immediate = false } = {}) {
169
+ if (reconciling) {
170
+ // Si hay una en curso, encadenamos otra: si no, una mutacion que cae justo
171
+ // en medio se pierde.
172
+ if (immediate) state.reconcileAgain = true;
173
+ return;
174
+ }
175
+ reconciling = true;
176
+ try {
177
+ const remote = await api('/api/state');
178
+ syncStale = false;
179
+ renderSyncState();
180
+ applyState(diffState(localState({ panes: state.panes, asks: state.pendingAsks.keys() }), remote));
181
+ lastState = remote;
182
+ scheduleReconcile();
183
+ } catch (error) {
184
+ // Esto antes era un `.catch(() => null)` silencioso: un fallo de red se via
185
+ // exactamente igual que "no hay nada pendiente", y por eso no se podia
186
+ // diagnosticar nada.
187
+ syncStale = true;
188
+ renderSyncState(error);
189
+ scheduleReconcile();
190
+ } finally {
191
+ reconciling = false;
192
+ if (state.reconcileAgain) {
193
+ state.reconcileAgain = false;
194
+ reconcile();
195
+ }
196
+ }
197
+ }
198
+
199
+ export function scheduleReconcile() {
200
+ if (reconcileTimer) clearTimeout(reconcileTimer);
201
+ // Con un permiso pendiente hay algo esperandonos: la latencia se nota.
202
+ reconcileTimer = setTimeout(() => reconcile(), pollDelayMs(lastState));
203
+ }
204
+
205
+ export function applyState(diff) {
206
+ for (const id of diff.gone) closeTab(id);
207
+ for (const id of diff.toDetach) reloadPane(id);
208
+ for (const id of diff.toAttach) reloadPane(id);
209
+ for (const askId of diff.resolvedAsks) {
210
+ state.pendingAsks.delete(askId);
211
+ // `null` = alguien lo resolvio en otro lado: la tarjeta ya se cerro alla y
212
+ // solo hay que apagarla.
213
+ resolvePermissionCard(state.permissionCards.get(askId), null);
214
+ }
215
+ for (const ask of diff.newAsks) {
216
+ if (state.pendingAsks.has(ask.askId)) continue;
217
+ state.pendingAsks.set(ask.askId, ask);
218
+ toast(`Permiso pendiente: ${ask.name || 'una tool'}${state.panes.has(ask.sessionId) ? '' : ' en otra sesión'}.`);
219
+ }
220
+ // Los permisos pendientes se pintan siempre, sin importar por donde llegaron:
221
+ // asi tambien se ven cuando el canal estaba caido y llego por el snapshot.
222
+ for (const [askId, ask] of state.pendingAsks) {
223
+ const record = state.panes.get(ask.sessionId);
224
+ if (record && !livePermission(askId)) showStandalonePermission(record, ask);
225
+ }
226
+ state.remoteActive = new Set(diff.remoteActive);
227
+ renderSessions(diff.sessions);
228
+ renderTabs();
229
+ renderAlertSignals();
230
+ }
231
+
232
+ /* --- señales de alerta: que se note aun mirando otra cosa ----------------- */
233
+
234
+ export const FAVICON_IDLE = '/favicon.ico';
235
+ export const FAVICON_SOURCE = '/icons/icon-192x192.png';
236
+ export let faviconAlert = '';
237
+ export let faviconAlertPending = null;
238
+
239
+ /**
240
+ * El favicon de alerta se dibuja sobre una copia del logo (mismo origen), para
241
+ * que el punto rojo combine con el icono real y no con un SVG desincronizado.
242
+ */
243
+ export function alertFavicon() {
244
+ if (faviconAlert) return Promise.resolve(faviconAlert);
245
+ if (faviconAlertPending) return faviconAlertPending;
246
+ faviconAlertPending = new Promise((resolve) => {
247
+ const img = new Image();
248
+ img.onload = () => {
249
+ try {
250
+ const canvas = document.createElement('canvas');
251
+ canvas.width = 64;
252
+ canvas.height = 64;
253
+ const ctx = canvas.getContext('2d');
254
+ ctx.drawImage(img, 0, 0, 64, 64);
255
+ ctx.beginPath();
256
+ ctx.arc(64 - 16, 16, 13, 0, Math.PI * 2);
257
+ ctx.fillStyle = '#d9704a';
258
+ ctx.fill();
259
+ ctx.lineWidth = 3;
260
+ ctx.strokeStyle = 'rgba(35,26,23,0.9)';
261
+ ctx.stroke();
262
+ faviconAlert = canvas.toDataURL('image/png');
263
+ resolve(faviconAlert);
264
+ } catch {
265
+ resolve(FAVICON_IDLE);
266
+ }
267
+ };
268
+ img.onerror = () => resolve(FAVICON_IDLE);
269
+ img.src = FAVICON_SOURCE;
270
+ });
271
+ return faviconAlertPending;
272
+ }
273
+
274
+ export let baseTitle = '';
275
+
276
+ /**
277
+ * Cuando iOS manda la pestana a background mata los timers y los SSE. El
278
+ * titulo y el favicon son las **unicas** señales que quedan, asi que los dos
279
+ * tienen que marcar que hay un permiso esperando.
280
+ */
281
+ export function renderAlertSignals() {
282
+ const pending = state.pendingAsks.size + state.pendingQuestions.size;
283
+ if (!baseTitle) baseTitle = document.title;
284
+ const clean = baseTitle.replace(/^\(⚠ \d+\) /, '');
285
+ document.title = pending > 0 ? `(⚠ ${pending}) ${clean}` : clean;
286
+ const link = document.querySelector('link[rel="icon"]');
287
+ if (link) {
288
+ if (pending > 0) alertFavicon().then((href) => { link.href = href; });
289
+ else link.href = FAVICON_IDLE;
290
+ }
291
+ }
292
+
293
+ /* --- canal global: solo latencia ------------------------------------------ */
294
+
295
+ /**
296
+ * Un SSE por pestana para enterarse al instante. **No decide nada**: dispara
297
+ * `reconcile()`. Si se cae -que es lo normal en un tunel, y lo que hace iOS al
298
+ * mandar la pestana a background- solo perdemos inmediatez, no correctitud.
299
+ */
300
+ export function handleGlobalEvent(event) {
301
+ // No decide nada: solo pide el estado fresco. El diff es el unico que sabe
302
+ // que hay que hacer.
303
+ if (event === 'run_start' || event === 'run_end' || event === 'permission' || event === 'permission_resolved' || event === 'snapshot') {
304
+ reconcile({ immediate: true });
305
+ }
306
+ }
307
+
308
+ export let eventsController = null;
309
+ export let channelState = 'off';
310
+ export let channelReason = '';
311
+
312
+ export function renderChannelState() {
313
+ const node = $('#sb-channel');
314
+ if (!node) return;
315
+ if (channelState === 'live') {
316
+ node.hidden = true;
317
+ return;
318
+ }
319
+ node.hidden = false;
320
+ node.className = `sb-channel ${channelState}`;
321
+ node.textContent = channelState === 'off' ? 'sin canal' : 'reconectando…';
322
+ node.title = channelReason
323
+ ? `Canal de eventos caido (${channelReason}). Los cambios llegan igual por sondeo cada ${Math.round(pollDelayMs(lastState) / 1000)} s, pero con retardo.`
324
+ : 'Canal de eventos: sin novedades instantaneas.';
325
+ }
326
+
327
+ /** El sondeo de /api/state tambien puede caerse; eso si hay que verlo. */
328
+ export function renderSyncState(error) {
329
+ const node = $('#sb-sync');
330
+ if (!node) return;
331
+ node.hidden = !syncStale;
332
+ node.className = 'sb-channel retrying';
333
+ node.textContent = 'sin sincronía';
334
+ node.title = error
335
+ ? `No pude leer el estado del server: ${error.message}. Reintentando cada ${Math.round(pollDelayMs(lastState) / 1000)} s.`
336
+ : 'No pude leer el estado del server. Reintentando.';
337
+ }
338
+
339
+ export async function followEvents() {
340
+ if (eventsController && !eventsController.signal.aborted) return;
341
+ const controller = new AbortController();
342
+ eventsController = controller;
343
+ channelState = 'waiting';
344
+ channelReason = '';
345
+ renderChannelState();
346
+ await followRun({
347
+ signal: controller.signal,
348
+ attempts: Infinity,
349
+ backoff: [1000, 2000, 5000, 10000, 15000],
350
+ onRetry: ({ attempt, error }) => {
351
+ channelState = 'retrying';
352
+ channelReason = error?.message ? `${error.message} (intento ${attempt})` : `sin datos (intento ${attempt})`;
353
+ renderChannelState();
354
+ },
355
+ open: (cursor, signal) =>
356
+ openStream('/api/events', { signal, silenceMs: CHANNEL_SILENCE_MS }, (event, data) => {
357
+ channelState = 'live';
358
+ channelReason = '';
359
+ renderChannelState();
360
+ handleGlobalEvent(event, data);
361
+ }),
362
+ })
363
+ .catch((error) => {
364
+ // Un AbortError es que estamos cerrando. Cualquier otra cosa es un bug de
365
+ // la consola: se avisa en vez de dejar un "reconectando" que no dice nada.
366
+ if (error?.name === 'AbortError') return;
367
+ channelState = 'off';
368
+ channelReason = error?.message ?? String(error);
369
+ renderChannelState();
370
+ toast(`Canal de eventos roto: ${error?.message ?? error}`);
371
+ })
372
+ .finally(() => {
373
+ if (eventsController === controller) {
374
+ channelState = 'off';
375
+ renderChannelState();
376
+ }
377
+ });
378
+ }
379
+
380
+ /* --- ciclo de vida --------------------------------------------------------- */
381
+
382
+ export function watchLifecycle() {
383
+ // Volver a `visible` es el momento de reconciliar: iOS congela los timers en
384
+ // background, asi que esperar al proximo tick puede ser Mucho tiempo.
385
+ const wake = () => {
386
+ reconcile({ immediate: true });
387
+ sendPresence();
388
+ if (!eventsController || eventsController.signal.aborted) followEvents();
389
+ };
390
+ document.addEventListener('visibilitychange', () => {
391
+ if (document.visibilityState === 'visible') wake();
392
+ });
393
+ window.addEventListener('pageshow', wake);
394
+ window.addEventListener('online', wake);
395
+ window.addEventListener('focus', wake);
396
+ // La cache del server dura 10 min: revisar al volver a la pestaña es barato.
397
+ refreshUpdate();
398
+ }
399
+
400
+ /* --- actualizacion de version --------------------------------------------- */
401
+
402
+ export const UPDATE_DISMISS_KEY = 'tamnora-update-dismissed';
403
+ export const updateBanner = $('#update-banner');
404
+ export const updateBannerText = $('#update-banner-text');
405
+ export let updateInfo = null;
406
+
407
+ export function showUpdateBanner(info, { force = false } = {}) {
408
+ if (!updateBanner || !info?.updateAvailable) return;
409
+ if (!force && storage.get(UPDATE_DISMISS_KEY) === String(info.latest)) return;
410
+ updateInfo = info;
411
+ updateBannerText.textContent = `Hay una versión nueva (v${info.latest}); tenés la v${info.current}.`;
412
+ updateBanner.hidden = false;
413
+ const ver = document.querySelector('.brand .ver');
414
+ if (ver) {
415
+ ver.classList.add('has-update');
416
+ ver.title = `Actualización disponible: v${info.latest}`;
417
+ }
418
+ }
419
+
420
+ export async function refreshUpdate() {
421
+ if (!updateBanner) return;
422
+ const info = await api('/api/update').catch(() => null);
423
+ if (info?.updateAvailable) showUpdateBanner(info);
424
+ }
425
+
426
+ export function dismissUpdate() {
427
+ if (updateInfo?.latest) storage.set(UPDATE_DISMISS_KEY, String(updateInfo.latest));
428
+ if (updateBanner) updateBanner.hidden = true;
429
+ }
430
+
431
+ /** Chequeo manual (click en la version): fuerza la consulta y avisa el resultado. */
432
+ export async function forceUpdateCheck() {
433
+ const ver = document.querySelector('.brand .ver');
434
+ const original = ver?.textContent;
435
+ if (ver) ver.textContent = '…';
436
+ const info = await api('/api/update?force=1').catch(() => null);
437
+ if (ver) ver.textContent = info?.current ? `v${info.current}` : original;
438
+ if (!info) return toast('No pude consultar la versión.');
439
+ if (info.updateAvailable) {
440
+ showUpdateBanner(info, { force: true });
441
+ toast(`Hay una versión nueva: v${info.latest}`);
442
+ } else if (info.error) {
443
+ toast(`No pude consultar npm: ${info.error}`);
444
+ } else {
445
+ toast(`Estás al día (v${info.current}).`);
446
+ }
447
+ }
448
+
449
+ /** Espera a que el server reiniciado vuelva a responder `/api/health`. */
450
+ export async function waitServerVersion(tries = 45) {
451
+ for (let i = 0; i < tries; i++) {
452
+ await new Promise((r) => setTimeout(r, 2000));
453
+ const health = await fetch('/api/health', { cache: 'no-store' })
454
+ .then((r) => (r.ok ? r.json() : null))
455
+ .catch(() => null);
456
+ if (health?.version) return health.version;
457
+ }
458
+ return '';
459
+ }
460
+
461
+ export async function runUpdate() {
462
+ if (!updateInfo) return;
463
+ if (
464
+ !(await showConfirm(`¿Actualizar a la v${updateInfo.latest}? La consola se reinicia sola.`, {
465
+ title: 'Actualizar Tamnora',
466
+ confirmLabel: 'actualizar',
467
+ }))
468
+ ) {
469
+ return;
470
+ }
471
+ const runBtn = $('#update-run');
472
+ const laterBtn = $('#update-later');
473
+ if (runBtn) runBtn.disabled = true;
474
+ if (laterBtn) laterBtn.disabled = true;
475
+ updateBannerText.textContent = `Actualizando a v${updateInfo.latest}…`;
476
+ const res = await api('/api/update', { method: 'POST' }).catch((error) => ({ error: error.message }));
477
+ if (res?.error) {
478
+ updateBannerText.textContent = `No se pudo actualizar: ${res.error}`;
479
+ if (runBtn) runBtn.disabled = false;
480
+ if (laterBtn) laterBtn.disabled = false;
481
+ return;
482
+ }
483
+ updateBannerText.textContent = 'Instalando la versión nueva… la consola se va a reconectar.';
484
+ const version = await waitServerVersion();
485
+ if (version) {
486
+ updateBannerText.textContent = `Actualizado a v${version}. Recargando…`;
487
+ setTimeout(() => location.reload(), 900);
488
+ } else {
489
+ updateBannerText.textContent = 'No pude confirmar el reinicio. Recargá la página en unos segundos.';
490
+ if (runBtn) runBtn.disabled = false;
491
+ if (laterBtn) laterBtn.disabled = false;
492
+ }
493
+ }
494
+
495
+ export const brandVerEl = document.querySelector('.brand .ver');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.24.1",
3
+ "version": "0.24.2",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",