@danieltmn/tamnora 0.21.2 → 0.22.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.
- package/CHANGELOG.md +9 -0
- package/harness/packages/core/src/agent/runAgent.js +9 -5
- package/harness/packages/core/src/index.js +1 -0
- package/harness/packages/core/src/mcp/stdio.js +15 -0
- package/harness/packages/core/src/net/hosts.js +70 -0
- package/harness/packages/core/src/redact.js +2 -0
- package/harness/packages/core/src/tools/executors.js +19 -24
- package/harness/packages/core/src/tools/index.js +1 -1
- package/harness/packages/core/src/tools/paths.js +35 -5
- package/harness/packages/core/src/tools/permissions.js +41 -2
- package/harness/packages/core/src/tools/registry.js +8 -3
- package/harness/packages/core/src/tunnel.js +15 -5
- package/harness/packages/core/test/tools.test.js +44 -1
- package/harness/packages/server/public/app.js +343 -6754
- package/harness/packages/server/public/chat.js +1893 -0
- package/harness/packages/server/public/chrome.js +812 -0
- package/harness/packages/server/public/commands.js +403 -0
- package/harness/packages/server/public/core.js +209 -0
- package/harness/packages/server/public/explorer.js +625 -0
- package/harness/packages/server/public/index.html +19 -19
- package/harness/packages/server/public/sessions.js +274 -0
- package/harness/packages/server/public/settings.js +1076 -0
- package/harness/packages/server/public/styles.css +25 -2
- package/harness/packages/server/public/system.js +495 -0
- package/harness/packages/server/public/voice.js +1058 -0
- package/harness/packages/server/src/auth.js +12 -10
- package/harness/packages/server/src/lib/agentRun.js +6 -1
- package/harness/packages/server/src/lib/channels/telegram.js +1 -0
- package/harness/packages/server/src/lib/presence.js +12 -1
- package/harness/packages/server/src/lib/scheduler.js +11 -2
- package/harness/packages/server/src/lib/tasks.js +20 -3
- package/harness/packages/server/src/lib/workspace.js +90 -2
- package/harness/packages/server/src/routes/auth.js +8 -4
- package/harness/packages/server/src/routes/chat.js +15 -4
- package/harness/packages/server/src/routes/preview.js +10 -2
- package/harness/packages/server/src/routes/providers.js +12 -9
- package/harness/packages/server/src/server.js +19 -0
- package/harness/packages/server/test/chat.test.js +586 -0
- package/harness/packages/server/test/helpers.js +107 -0
- package/harness/packages/server/test/ops.test.js +511 -0
- package/harness/packages/server/test/runs.test.js +816 -0
- package/harness/packages/server/test/security.test.js +112 -9
- package/harness/packages/server/test/sessions.test.js +437 -0
- package/harness/packages/server/test/settings.test.js +465 -0
- package/harness/tamnora.example.json +1 -1
- package/package.json +1 -1
- package/harness/packages/server/test/server.test.js +0 -2885
|
@@ -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.
|
|
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.22.0</span></div>
|
|
79
79
|
<nav class="tabs">
|
|
80
80
|
<button class="tab active" data-tab="chat">chat</button>
|
|
81
81
|
<button class="tab" data-tab="providers">proveedores</button>
|
|
@@ -253,7 +253,7 @@
|
|
|
253
253
|
</svg>
|
|
254
254
|
</button>
|
|
255
255
|
<input id="image-input" type="file" accept="image/*" multiple hidden />
|
|
256
|
-
<textarea id="chat-input" rows="1" placeholder="escribí un mensaje…" title="Enter envía · Shift+Enter salto" spellcheck="false"></textarea>
|
|
256
|
+
<textarea id="chat-input" rows="1" placeholder="escribí un mensaje…" title="Enter envía · Shift+Enter salto" aria-label="mensaje para el asistente" spellcheck="false"></textarea>
|
|
257
257
|
<button type="button" id="model-pill" class="ctrl-pill model-pill" title="proveedor y modelo activos">
|
|
258
258
|
<span id="model-pill-label">—</span>
|
|
259
259
|
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
|
@@ -312,7 +312,7 @@
|
|
|
312
312
|
<div id="xp-files" class="xp-pane">
|
|
313
313
|
<div id="explorer-root" class="explorer-cwd" title=""></div>
|
|
314
314
|
<div class="explorer-filter">
|
|
315
|
-
<input id="explorer-filter" placeholder="filtrar por nombre…" spellcheck="false" />
|
|
315
|
+
<input id="explorer-filter" placeholder="filtrar por nombre…" aria-label="filtrar archivos por nombre" spellcheck="false" />
|
|
316
316
|
</div>
|
|
317
317
|
<ul id="explorer-tree" class="tree"></ul>
|
|
318
318
|
<div id="explorer-status" class="explorer-status"></div>
|
|
@@ -320,7 +320,7 @@
|
|
|
320
320
|
|
|
321
321
|
<div id="xp-preview" class="xp-pane" hidden>
|
|
322
322
|
<div class="xp-bar">
|
|
323
|
-
<input id="preview-url" placeholder="http://127.0.0.1:5173" spellcheck="false" />
|
|
323
|
+
<input id="preview-url" placeholder="http://127.0.0.1:5173" aria-label="URL de la vista previa" spellcheck="false" />
|
|
324
324
|
<button id="preview-open" class="btn" type="button">abrir</button>
|
|
325
325
|
<select id="preview-provider" title="cómo exponer el puerto para acceso remoto" aria-label="proveedor de acceso remoto">
|
|
326
326
|
<option value="tailscale">tailscale</option>
|
|
@@ -366,7 +366,7 @@
|
|
|
366
366
|
<span id="git-sync" class="muted grow"></span>
|
|
367
367
|
</div>
|
|
368
368
|
<div class="explorer-filter">
|
|
369
|
-
<input id="git-filter" placeholder="filtrar por nombre…" spellcheck="false" />
|
|
369
|
+
<input id="git-filter" aria-label="filtrar archivos por nombre" placeholder="filtrar por nombre…" spellcheck="false" />
|
|
370
370
|
</div>
|
|
371
371
|
<ul id="git-list" class="git-list"></ul>
|
|
372
372
|
<div id="git-status" class="explorer-status"></div>
|
|
@@ -486,10 +486,10 @@
|
|
|
486
486
|
<textarea id="eval-tasks" rows="8" spellcheck="false" placeholder='{"id":"t1","input":"¿2+2?","expect":{"equals":"4"}}'></textarea>
|
|
487
487
|
</label>
|
|
488
488
|
<div class="row">
|
|
489
|
-
<input id="eval-models" placeholder="modelos separados por coma" spellcheck="false" />
|
|
490
|
-
<input id="eval-trials" type="number" min="1" value="1" title="trials" />
|
|
491
|
-
<input id="eval-concurrency" type="number" min="1" value="2" title="concurrencia" />
|
|
492
|
-
<input id="eval-judge" placeholder="judge-model (opcional)" spellcheck="false" />
|
|
489
|
+
<input id="eval-models" aria-label="modelos para evaluar (separados por coma)" placeholder="modelos separados por coma" spellcheck="false" />
|
|
490
|
+
<input id="eval-trials" aria-label="trials por tarea" type="number" min="1" value="1" title="trials" />
|
|
491
|
+
<input id="eval-concurrency" aria-label="concurrencia" type="number" min="1" value="2" title="concurrencia" />
|
|
492
|
+
<input id="eval-judge" aria-label="judge-model (opcional)" placeholder="judge-model (opcional)" spellcheck="false" />
|
|
493
493
|
<button id="eval-run" class="btn primary">correr</button>
|
|
494
494
|
</div>
|
|
495
495
|
<div id="eval-summary" class="summary"></div>
|
|
@@ -633,15 +633,15 @@
|
|
|
633
633
|
<span id="mcp-count" class="muted"></span>
|
|
634
634
|
</div>
|
|
635
635
|
<div class="import-bar">
|
|
636
|
-
<input id="mcp-pinned" placeholder="tools/servidores fijos (coma), ej. mcp__postgres__query" spellcheck="false" />
|
|
636
|
+
<input id="mcp-pinned" aria-label="tools/servidores MCP fijados" placeholder="tools/servidores fijos (coma), ej. mcp__postgres__query" spellcheck="false" />
|
|
637
637
|
<button id="mcp-pinned-save" class="btn" type="button">fijar</button>
|
|
638
638
|
</div>
|
|
639
639
|
<div class="import-bar">
|
|
640
|
-
<input id="mcp-import" placeholder="nombre del registro oficial, URL o JSON (bloque mcpServers)" spellcheck="false" />
|
|
640
|
+
<input id="mcp-import" aria-label="importar servidor MCP" placeholder="nombre del registro oficial, URL o JSON (bloque mcpServers)" spellcheck="false" />
|
|
641
641
|
<button id="mcp-import-btn" class="btn" type="button">importar</button>
|
|
642
642
|
</div>
|
|
643
643
|
<div class="import-bar">
|
|
644
|
-
<input id="mcp-search" placeholder="buscar en el registro oficial (ej. github, postgres)" spellcheck="false" />
|
|
644
|
+
<input id="mcp-search" aria-label="buscar en el registro de MCP" placeholder="buscar en el registro oficial (ej. github, postgres)" spellcheck="false" />
|
|
645
645
|
<button id="mcp-search-btn" class="btn" type="button">buscar</button>
|
|
646
646
|
</div>
|
|
647
647
|
<div id="mcp-import-status" class="summary"></div>
|
|
@@ -663,11 +663,11 @@
|
|
|
663
663
|
Instrucciones especializadas en la casa de Tamnora (<code>skills/</code>). Las habilitadas se inyectan al system prompt.
|
|
664
664
|
</p>
|
|
665
665
|
<div class="import-bar">
|
|
666
|
-
<input id="skill-import" placeholder="https://skills.sh/owner/repo/skill" spellcheck="false" />
|
|
666
|
+
<input id="skill-import" aria-label="importar skill" placeholder="https://skills.sh/owner/repo/skill" spellcheck="false" />
|
|
667
667
|
<button id="skill-import-btn" class="btn" type="button">importar</button>
|
|
668
668
|
</div>
|
|
669
669
|
<div class="import-bar">
|
|
670
|
-
<input id="skill-search" placeholder="buscar skills en GitHub (ej. testing, react)" spellcheck="false" />
|
|
670
|
+
<input id="skill-search" aria-label="buscar skills en GitHub" placeholder="buscar skills en GitHub (ej. testing, react)" spellcheck="false" />
|
|
671
671
|
<button id="skill-search-btn" class="btn" type="button">buscar</button>
|
|
672
672
|
</div>
|
|
673
673
|
<div id="skill-import-status" class="summary"></div>
|
|
@@ -980,12 +980,12 @@
|
|
|
980
980
|
</div>
|
|
981
981
|
<div class="voz-range">
|
|
982
982
|
<span class="muted">tamaño del chat</span>
|
|
983
|
-
<input id="apariencia-chat-font" type="range" min="12" max="20" step="0.5" />
|
|
983
|
+
<input id="apariencia-chat-font" aria-label="tamano del texto del chat" type="range" min="12" max="20" step="0.5" />
|
|
984
984
|
<output id="apariencia-chat-font-out">14.5px</output>
|
|
985
985
|
</div>
|
|
986
986
|
<div class="voz-range">
|
|
987
987
|
<span class="muted">escala de la interfaz</span>
|
|
988
|
-
<input id="apariencia-scale" type="range" min="80" max="130" step="5" />
|
|
988
|
+
<input id="apariencia-scale" aria-label="escala general de la interfaz" type="range" min="80" max="130" step="5" />
|
|
989
989
|
<output id="apariencia-scale-out">100%</output>
|
|
990
990
|
</div>
|
|
991
991
|
<div class="row">
|
|
@@ -1034,11 +1034,11 @@
|
|
|
1034
1034
|
<nav id="folder-crumbs" class="crumbs" aria-label="ruta actual"></nav>
|
|
1035
1035
|
</div>
|
|
1036
1036
|
<div class="folder-search">
|
|
1037
|
-
<input id="folder-goto" spellcheck="false" placeholder="filtrar carpetas, o pegá una ruta y Enter" />
|
|
1037
|
+
<input id="folder-goto" aria-label="ir a una ruta o filtrar carpetas" spellcheck="false" placeholder="filtrar carpetas, o pegá una ruta y Enter" />
|
|
1038
1038
|
<button type="button" id="folder-go" class="btn">ir</button>
|
|
1039
1039
|
</div>
|
|
1040
1040
|
<div id="folder-create" class="folder-create" hidden>
|
|
1041
|
-
<input id="folder-new-name" spellcheck="false" placeholder="nombre de la nueva carpeta" />
|
|
1041
|
+
<input id="folder-new-name" aria-label="nombre de la nueva carpeta" spellcheck="false" placeholder="nombre de la nueva carpeta" />
|
|
1042
1042
|
<button type="button" id="folder-new-create" class="btn primary">crear</button>
|
|
1043
1043
|
<button type="button" id="folder-new-cancel" class="btn">cancelar</button>
|
|
1044
1044
|
</div>
|
|
@@ -1222,7 +1222,7 @@
|
|
|
1222
1222
|
<circle cx="11" cy="11" r="8" />
|
|
1223
1223
|
<path d="m21 21-4.3-4.3" />
|
|
1224
1224
|
</svg>
|
|
1225
|
-
<input id="palette-input" type="text" placeholder="buscar sesiones y mensajes, o escribí un comando…" spellcheck="false" autocomplete="off" />
|
|
1225
|
+
<input id="palette-input" aria-label="buscar sesiones, mensajes y comandos" type="text" placeholder="buscar sesiones y mensajes, o escribí un comando…" spellcheck="false" autocomplete="off" />
|
|
1226
1226
|
<span class="palette-hint">esc</span>
|
|
1227
1227
|
</div>
|
|
1228
1228
|
<ul id="palette-list" class="palette-list"></ul>
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addMessage,
|
|
3
|
+
addPermissionCard,
|
|
4
|
+
attachRun,
|
|
5
|
+
basename,
|
|
6
|
+
chatInput,
|
|
7
|
+
idleWaiters,
|
|
8
|
+
interruptBtn,
|
|
9
|
+
livePermission,
|
|
10
|
+
panesEl,
|
|
11
|
+
persistTabs,
|
|
12
|
+
presenceClosed,
|
|
13
|
+
refreshSessions,
|
|
14
|
+
renderFooterActivity,
|
|
15
|
+
renderQueue,
|
|
16
|
+
renderStoredMessages,
|
|
17
|
+
showPane,
|
|
18
|
+
showStandaloneQuestion,
|
|
19
|
+
stopBtn,
|
|
20
|
+
submitBtn,
|
|
21
|
+
tabbarEl,
|
|
22
|
+
} from './chat.js';
|
|
23
|
+
import {
|
|
24
|
+
applySessionAgent,
|
|
25
|
+
isMobile,
|
|
26
|
+
openFolderPicker,
|
|
27
|
+
renderTokens,
|
|
28
|
+
setCwd,
|
|
29
|
+
setSidebarOpen,
|
|
30
|
+
updateStatus,
|
|
31
|
+
} from './chrome.js';
|
|
32
|
+
import { notify } from './commands.js';
|
|
33
|
+
import { $, api, clear, el, icon, state, storage } from './core.js';
|
|
34
|
+
import { renderAlertSignals } from './system.js';
|
|
35
|
+
|
|
36
|
+
/* --- session tabs --------------------------------------------------------- */
|
|
37
|
+
|
|
38
|
+
export function registerSession(session, messages = [], tokens = { in: 0, out: 0 }, running = false, permissions = [], questions = []) {
|
|
39
|
+
const id = session.id;
|
|
40
|
+
if (state.panes.has(id)) return state.panes.get(id);
|
|
41
|
+
const pane = document.createElement('div');
|
|
42
|
+
pane.className = 'messages';
|
|
43
|
+
pane.dataset.session = id;
|
|
44
|
+
panesEl.appendChild(pane);
|
|
45
|
+
const record = {
|
|
46
|
+
id,
|
|
47
|
+
el: pane,
|
|
48
|
+
title: session.title || '(sin título)',
|
|
49
|
+
cwd: session.cwd || '',
|
|
50
|
+
// Tipo de agente de la sesion: se aplica a los controles al abrirla y se
|
|
51
|
+
// persiste al cambiarlos. Cada pestaña puede correr con uno distinto.
|
|
52
|
+
provider: session.provider || '',
|
|
53
|
+
model: session.model || '',
|
|
54
|
+
mode: session.mode || '',
|
|
55
|
+
working: false,
|
|
56
|
+
tokens,
|
|
57
|
+
lastTurn: { prompt: 0, completion: 0 },
|
|
58
|
+
};
|
|
59
|
+
state.panes.set(id, record);
|
|
60
|
+
state.tabOrder.push(id);
|
|
61
|
+
renderStoredMessages(messages, pane);
|
|
62
|
+
if (!messages.length) {
|
|
63
|
+
pane.appendChild(el('div', 'empty', `Sesión vacía${session.cwd ? ` en ${basename(session.cwd)}` : ''}.`));
|
|
64
|
+
}
|
|
65
|
+
if (running) attachRun(id, pane);
|
|
66
|
+
// Los permisos pendientes no son mensajes: vienen aparte en el detalle de la
|
|
67
|
+
// sesion. Sin esto, recargar con un permiso en el aire dejaba el run
|
|
68
|
+
// bloqueado esperando una decision que no se podia tomar.
|
|
69
|
+
for (const ask of permissions) showStandalonePermission(record, ask);
|
|
70
|
+
for (const ask of questions) showStandaloneQuestion(record, ask);
|
|
71
|
+
return record;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Un permiso que sigue esperando, recreado desde `/api/state` o del detalle de
|
|
76
|
+
* la sesion. Va en una tarjeta propia al final del pane (no hay mensaje al que
|
|
77
|
+
* colgarse todavia).
|
|
78
|
+
*/
|
|
79
|
+
export function showStandalonePermission(record, ask) {
|
|
80
|
+
if (livePermission(ask.askId)) return;
|
|
81
|
+
record.el.querySelector('.empty')?.remove();
|
|
82
|
+
const holder = addMessage('assistant', '', record.el);
|
|
83
|
+
holder.textEl.remove();
|
|
84
|
+
addPermissionCard(holder, { askId: ask.askId, name: ask.name, args: ask.args });
|
|
85
|
+
state.pendingAsks.set(ask.askId, { sessionId: record.id, name: ask.name ?? '', args: ask.args ?? {} });
|
|
86
|
+
renderAlertSignals();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function renderTabs() {
|
|
90
|
+
clear(tabbarEl);
|
|
91
|
+
tabbarEl.hidden = !state.tabOrder.length;
|
|
92
|
+
for (const id of state.tabOrder) {
|
|
93
|
+
const record = state.panes.get(id);
|
|
94
|
+
if (!record) continue;
|
|
95
|
+
const tab = el('button', `session-tab${id === state.activeTab ? ' active' : ''}`);
|
|
96
|
+
tab.type = 'button';
|
|
97
|
+
tab.title = `${record.title}${record.cwd ? ` · ${record.cwd}` : ''}`;
|
|
98
|
+
if (record.working) tab.appendChild(el('span', 'tab-spinner'));
|
|
99
|
+
tab.appendChild(el('span', 'tab-title', record.title || '(sin título)'));
|
|
100
|
+
const close = el('span', 'tab-close');
|
|
101
|
+
close.title = 'cerrar';
|
|
102
|
+
close.appendChild(icon('x', 12));
|
|
103
|
+
close.addEventListener('click', (event) => {
|
|
104
|
+
event.stopPropagation();
|
|
105
|
+
closeTab(id);
|
|
106
|
+
});
|
|
107
|
+
tab.appendChild(close);
|
|
108
|
+
tab.addEventListener('click', () => openTab(id));
|
|
109
|
+
tabbarEl.appendChild(tab);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function openTab(id) {
|
|
114
|
+
if (state.panes.has(id)) {
|
|
115
|
+
activateSession(id);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const data = await api(`/api/sessions/${id}`).catch((error) => {
|
|
119
|
+
notify(`No se pudo abrir la sesión: ${error.message}`, 'notice err');
|
|
120
|
+
return null;
|
|
121
|
+
});
|
|
122
|
+
if (!data) return;
|
|
123
|
+
registerSession(data.session, data.messages, data.tokens, data.running, data.permissions, data.questions);
|
|
124
|
+
activateSession(id);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function activateSession(id) {
|
|
128
|
+
const record = state.panes.get(id);
|
|
129
|
+
if (!record) return;
|
|
130
|
+
state.sessionId = id;
|
|
131
|
+
state.activeTab = id;
|
|
132
|
+
showPane(id);
|
|
133
|
+
if (record.cwd) setCwd(record.cwd);
|
|
134
|
+
if (stopBtn) stopBtn.hidden = !record.working;
|
|
135
|
+
syncInterruptBtn();
|
|
136
|
+
auditStatus(record);
|
|
137
|
+
updateStatus();
|
|
138
|
+
renderTokens();
|
|
139
|
+
applySessionAgent(record);
|
|
140
|
+
renderQueue(id);
|
|
141
|
+
renderTabs();
|
|
142
|
+
refreshSessions();
|
|
143
|
+
persistTabs();
|
|
144
|
+
chatInput.focus();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function updateSubmitLabel(working) {
|
|
148
|
+
if (!submitBtn) return;
|
|
149
|
+
submitBtn.dataset.working = working ? '1' : '0';
|
|
150
|
+
submitBtn.setAttribute('aria-label', working ? 'encolar' : 'enviar');
|
|
151
|
+
submitBtn.title = working ? 'El agente está trabajando: se envía al terminar' : 'enviar';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** El boton de interrumpir solo tiene sentido con un turno en curso y texto que mandar. */
|
|
155
|
+
export function syncInterruptBtn() {
|
|
156
|
+
if (!interruptBtn) return;
|
|
157
|
+
const record = state.panes.get(state.sessionId);
|
|
158
|
+
const working = Boolean(record?.working);
|
|
159
|
+
const hasInput = Boolean(chatInput.value.trim()) || state.attachments.length > 0;
|
|
160
|
+
interruptBtn.hidden = !working;
|
|
161
|
+
interruptBtn.disabled = !working || !hasInput;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Promesa que resuelve cuando el turno de la sesion deja de estar activo. */
|
|
165
|
+
export function whenIdle(sessionKey, timeoutMs = 8000) {
|
|
166
|
+
const record = state.panes.get(sessionKey);
|
|
167
|
+
if (!record?.working) return Promise.resolve();
|
|
168
|
+
return new Promise((resolve) => {
|
|
169
|
+
let timer = null;
|
|
170
|
+
const settle = () => {
|
|
171
|
+
if (timer) clearTimeout(timer);
|
|
172
|
+
const list = idleWaiters.get(sessionKey);
|
|
173
|
+
if (list) {
|
|
174
|
+
const index = list.indexOf(settle);
|
|
175
|
+
if (index >= 0) list.splice(index, 1);
|
|
176
|
+
if (!list.length) idleWaiters.delete(sessionKey);
|
|
177
|
+
}
|
|
178
|
+
resolve();
|
|
179
|
+
};
|
|
180
|
+
timer = setTimeout(settle, timeoutMs);
|
|
181
|
+
const list = idleWaiters.get(sessionKey) ?? [];
|
|
182
|
+
list.push(settle);
|
|
183
|
+
idleWaiters.set(sessionKey, list);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function auditStatus(record) {
|
|
188
|
+
submitBtn.disabled = false;
|
|
189
|
+
updateSubmitLabel(record.working);
|
|
190
|
+
$('#sb-rounds').textContent = record.working ? '' : 'listo';
|
|
191
|
+
$('#sb-rounds').classList.toggle('running', record.working);
|
|
192
|
+
renderFooterActivity(record.working ? 'thinking' : null);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function closeTab(id) {
|
|
196
|
+
const record = state.panes.get(id);
|
|
197
|
+
if (!record) return;
|
|
198
|
+
record.el.remove();
|
|
199
|
+
state.panes.delete(id);
|
|
200
|
+
state.queues.delete(id);
|
|
201
|
+
state.tabOrder = state.tabOrder.filter((item) => item !== id);
|
|
202
|
+
presenceClosed.add(id);
|
|
203
|
+
if (state.activeTab === id) {
|
|
204
|
+
const next = state.tabOrder[state.tabOrder.length - 1] ?? null;
|
|
205
|
+
if (next) {
|
|
206
|
+
activateSession(next);
|
|
207
|
+
} else {
|
|
208
|
+
state.sessionId = null;
|
|
209
|
+
state.activeTab = null;
|
|
210
|
+
showPane(null);
|
|
211
|
+
renderTabs();
|
|
212
|
+
renderTokens();
|
|
213
|
+
refreshTokensDefaults();
|
|
214
|
+
refreshSessions();
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
renderTabs();
|
|
218
|
+
}
|
|
219
|
+
persistTabs();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function refreshTokensDefaults() {
|
|
223
|
+
$('#sb-rounds').textContent = 'listo';
|
|
224
|
+
$('#sb-rounds').classList.remove('running');
|
|
225
|
+
submitBtn.disabled = false;
|
|
226
|
+
updateSubmitLabel(false);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function setWorking(id, working) {
|
|
230
|
+
const record = state.panes.get(id);
|
|
231
|
+
// `attached` tiene que acompanar a `working`: mientras el pane sigue un run
|
|
232
|
+
// (tiene un controller vivo) el reconciliador NO debe recrearlo, o el
|
|
233
|
+
// `replaceChildren` de `renderPane` borra lo que el agente viene haciendo y
|
|
234
|
+
// solo se recupera recargando.
|
|
235
|
+
if (record) record.working = record.attached = working;
|
|
236
|
+
renderTabs();
|
|
237
|
+
if (!working) {
|
|
238
|
+
const waiters = idleWaiters.get(id);
|
|
239
|
+
if (waiters) {
|
|
240
|
+
idleWaiters.delete(id);
|
|
241
|
+
for (const settle of [...waiters]) settle();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (id === state.sessionId) {
|
|
245
|
+
submitBtn.disabled = false;
|
|
246
|
+
updateSubmitLabel(working);
|
|
247
|
+
if (stopBtn) stopBtn.hidden = !working;
|
|
248
|
+
syncInterruptBtn();
|
|
249
|
+
$('#sb-rounds').textContent = working ? '' : 'listo';
|
|
250
|
+
$('#sb-rounds').classList.toggle('running', working);
|
|
251
|
+
renderFooterActivity(working ? 'thinking' : null);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function restoreTabs() {
|
|
256
|
+
const saved = storage.getJson('tamnora-open-tabs', null);
|
|
257
|
+
const ids = Array.isArray(saved?.ids) ? saved.ids : [];
|
|
258
|
+
for (const id of ids) {
|
|
259
|
+
if (state.panes.has(id)) continue;
|
|
260
|
+
const data = await api(`/api/sessions/${id}`).catch(() => null);
|
|
261
|
+
if (data) registerSession(data.session, data.messages, data.tokens, data.running, data.permissions, data.questions);
|
|
262
|
+
}
|
|
263
|
+
const active = saved?.active && state.panes.has(saved.active) ? saved.active : state.tabOrder[state.tabOrder.length - 1];
|
|
264
|
+
if (active) activateSession(active);
|
|
265
|
+
else renderTabs();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// El mismo handler para el boton del sidebar y el del topbar: en movil el
|
|
269
|
+
// sidebar es un drawer, asi que sin el del topbar crear una sesion eran dos
|
|
270
|
+
// toques y un menu escondido.
|
|
271
|
+
export function startNewSession() {
|
|
272
|
+
if (isMobile()) setSidebarOpen(false);
|
|
273
|
+
openFolderPicker();
|
|
274
|
+
}
|