@danieltmn/tamnora 0.5.1 → 0.6.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.
@@ -102,13 +102,14 @@ async function api(path, { method = 'GET', body } = {}) {
102
102
  return res.json();
103
103
  }
104
104
 
105
- async function streamPost(url, body, onEvent, signal) {
106
- const headers = { 'Content-Type': 'application/json' };
107
- if (csrfToken) headers['x-csrf'] = csrfToken;
105
+ async function streamSse(url, { method = 'GET', body, signal } = {}, onEvent) {
106
+ const headers = {};
107
+ if (body) headers['Content-Type'] = 'application/json';
108
+ if (method !== 'GET' && csrfToken) headers['x-csrf'] = csrfToken;
108
109
  const res = await fetch(url, {
109
- method: 'POST',
110
+ method,
110
111
  headers,
111
- body: JSON.stringify(body),
112
+ body: body ? JSON.stringify(body) : undefined,
112
113
  signal,
113
114
  });
114
115
  if (!res.ok) {
@@ -133,9 +134,11 @@ async function streamPost(url, body, onEvent, signal) {
133
134
  buffer = buffer.slice(index + 2);
134
135
  let event = 'message';
135
136
  let data = '';
137
+ let id = null;
136
138
  for (const line of frame.split('\n')) {
137
139
  if (line.startsWith('event:')) event = line.slice(6).trim();
138
140
  else if (line.startsWith('data:')) data += line.slice(5).trim();
141
+ else if (line.startsWith('id:')) id = line.slice(3).trim();
139
142
  }
140
143
  let parsed = {};
141
144
  if (data) {
@@ -145,11 +148,13 @@ async function streamPost(url, body, onEvent, signal) {
145
148
  parsed = { raw: data };
146
149
  }
147
150
  }
148
- onEvent(event, parsed);
151
+ onEvent(event, parsed, id);
149
152
  }
150
153
  }
151
154
  }
152
155
 
156
+ const streamPost = (url, body, onEvent, signal) => streamSse(url, { method: 'POST', body, signal }, onEvent);
157
+
153
158
  /* --- tabs ---------------------------------------------------------------- */
154
159
 
155
160
  const moreTab = $('#more-tab');
@@ -760,14 +765,21 @@ function copyMessage(message) {
760
765
  if (write?.catch) write.catch(() => {});
761
766
  }
762
767
 
763
- async function reloadPane(id) {
768
+ async function renderPane(id) {
764
769
  const record = state.panes.get(id);
765
- if (!record) return;
770
+ if (!record) return null;
766
771
  const detail = await api(`/api/sessions/${id}`).catch(() => null);
767
- if (!detail) return;
772
+ if (!detail) return null;
768
773
  record.el.replaceChildren();
769
- for (const stored of detail.messages) renderStored(stored, record.el);
774
+ renderStoredMessages(detail.messages, record.el);
770
775
  if (!detail.messages.length) record.el.appendChild(el('div', 'empty', 'Sesión vacía.'));
776
+ return detail;
777
+ }
778
+
779
+ async function reloadPane(id) {
780
+ const detail = await renderPane(id);
781
+ const record = state.panes.get(id);
782
+ if (detail?.running && record && !record.working) await attachRun(id, record.el);
771
783
  }
772
784
 
773
785
  async function regenerate(message) {
@@ -887,19 +899,165 @@ function renderToolOut(node, text) {
887
899
  }
888
900
  }
889
901
 
890
- function addToolCard(message, name, args) {
891
- const card = el('div', 'tool-card running');
892
- const head = el('div', 'tool-head');
893
- head.append(
894
- el('span', 'tool-glyph', '⏺'),
895
- el('span', 'tool-name', name),
896
- el('span', 'tool-args', truncate(args, 200))
897
- );
902
+ const READ_ONLY_TOOLS = new Set([
903
+ 'read',
904
+ 'list',
905
+ 'glob',
906
+ 'grep',
907
+ 'fetch_url',
908
+ 'git_status',
909
+ 'git_diff',
910
+ 'git_log',
911
+ 'task',
912
+ 'spawn_agent',
913
+ ]);
914
+ const toolCardIndex = new Map();
915
+
916
+ function isReadOnlyTool(name) {
917
+ const value = String(name ?? '');
918
+ if (READ_ONLY_TOOLS.has(value)) return true;
919
+ return /^mcp__.+__(read|list|search|get|fetch|query)/i.test(value);
920
+ }
921
+
922
+ function shortText(value, max = 80) {
923
+ const raw = String(value ?? '').replace(/\s+/g, ' ').trim();
924
+ return raw.length > max ? `${raw.slice(0, max - 1)}…` : raw;
925
+ }
926
+
927
+ function toolSummary(name, args) {
928
+ const a = args && typeof args === 'object' ? args : {};
929
+ switch (name) {
930
+ case 'read': {
931
+ const base = a.path ? shortText(a.path, 60) : '.';
932
+ const offset = Number(a.offset) || 0;
933
+ const limit = Number(a.limit) || 0;
934
+ if (offset && limit) return `${base}:${offset}-${offset + limit - 1}`;
935
+ if (offset) return `${base}:${offset}+`;
936
+ return base;
937
+ }
938
+ case 'write':
939
+ case 'edit':
940
+ return a.path ? shortText(a.path, 60) : 'archivo';
941
+ case 'list':
942
+ return a.path ? shortText(a.path, 60) : '.';
943
+ case 'glob':
944
+ return [a.pattern ? `"${shortText(a.pattern, 50)}"` : '', a.path ? `en ${shortText(a.path, 40)}` : '']
945
+ .filter(Boolean)
946
+ .join(' ');
947
+ case 'grep':
948
+ return [
949
+ a.pattern ? `"${shortText(a.pattern, 50)}"` : '',
950
+ a.include ? shortText(a.include, 30) : '',
951
+ a.path ? `en ${shortText(a.path, 40)}` : '',
952
+ ]
953
+ .filter(Boolean)
954
+ .join(' ');
955
+ case 'bash':
956
+ return shortText(String(a.command ?? '').split('\n')[0], 90);
957
+ case 'fetch_url':
958
+ return shortText(a.url, 90);
959
+ case 'git_diff':
960
+ return [a.ref ? `vs ${shortText(a.ref, 30)}` : a.staged ? 'staged' : 'unstaged', a.path ? shortText(a.path, 40) : '']
961
+ .filter(Boolean)
962
+ .join(' ');
963
+ case 'git_log':
964
+ return a.limit ? `${a.limit} commits` : '';
965
+ case 'git_commit':
966
+ return shortText(a.message, 70);
967
+ case 'task':
968
+ case 'spawn_agent':
969
+ return shortText(a.description || a.prompt, 80);
970
+ default:
971
+ return '';
972
+ }
973
+ }
974
+
975
+ function parseToolArgs(call) {
976
+ const raw = call?.function?.arguments ?? call?.arguments;
977
+ if (raw && typeof raw === 'object') return raw;
978
+ if (typeof raw !== 'string') return {};
979
+ try {
980
+ return JSON.parse(raw);
981
+ } catch {
982
+ return {};
983
+ }
984
+ }
985
+
986
+ function previewText(value, maxLines = 24, maxChars = 4000) {
987
+ const content = typeof value === 'string' ? value : JSON.stringify(value ?? '', null, 2) ?? '';
988
+ const lines = content.split('\n');
989
+ let preview = lines.slice(0, maxLines).join('\n');
990
+ let truncated = lines.length > maxLines;
991
+ if (preview.length > maxChars) {
992
+ preview = preview.slice(0, maxChars);
993
+ truncated = true;
994
+ }
995
+ return { preview, truncated };
996
+ }
997
+
998
+ function ensureToolsToggle(message) {
999
+ if (!message.toolsToggle) {
1000
+ const bar = el('div', 'tools-actions');
1001
+ const label = el('span', 'tools-count');
1002
+ const toggle = button('colapsar todo', () => {
1003
+ const details = [...message.tools.querySelectorAll('details.tool-card')];
1004
+ const anyOpen = details.some((node) => node.open);
1005
+ for (const node of details) node.open = !anyOpen;
1006
+ toggle.textContent = anyOpen ? 'expandir todo' : 'colapsar todo';
1007
+ }, 'tools-toggle');
1008
+ bar.append(label, toggle);
1009
+ message.tools.prepend(bar);
1010
+ message.toolsToggle = bar;
1011
+ message.toolsCount = label;
1012
+ message.toolsToggleBtn = toggle;
1013
+ }
1014
+ const total = message.tools.querySelectorAll('details.tool-card').length;
1015
+ const groups = new Set([...message.tools.querySelectorAll('details.tool-card')].map((n) => n.dataset.round ?? ''));
1016
+ message.toolsToggle.hidden = total < 2;
1017
+ message.toolsCount.textContent = groups.size > 1 ? `${total} pasos` : `${total} herramientas`;
1018
+ }
1019
+
1020
+ async function loadFullToolResult(entry, message) {
1021
+ if (entry.full || !entry.callId) return;
1022
+ const sessionId = paneOf(message)?.dataset.session;
1023
+ if (!sessionId) return;
1024
+ entry.more.disabled = true;
1025
+ entry.more.textContent = 'cargando…';
1026
+ const data = await api(
1027
+ `/api/sessions/${sessionId}/tool-result?callId=${encodeURIComponent(entry.callId)}`
1028
+ ).catch(() => null);
1029
+ entry.more.disabled = false;
1030
+ if (data?.content != null) {
1031
+ renderToolOut(entry.out, data.content);
1032
+ entry.full = true;
1033
+ entry.more.hidden = true;
1034
+ } else {
1035
+ entry.more.textContent = 'ver completo';
1036
+ }
1037
+ }
1038
+
1039
+ function addToolCard(message, name, args, { callId = null, open = null, round = null } = {}) {
1040
+ const collapsed = open === null ? isReadOnlyTool(name) : !open;
1041
+ const card = el('details', 'tool-card running');
1042
+ card.open = !collapsed;
1043
+ if (round != null) card.dataset.round = String(round);
1044
+ const head = el('summary', 'tool-head');
1045
+ const glyph = el('span', 'tool-glyph', '⏺');
1046
+ const argsEl = el('span', 'tool-args', toolSummary(name, args));
1047
+ head.append(glyph, el('span', 'tool-name', name), argsEl);
898
1048
  const out = el('div', 'tool-out');
899
- card.append(head, out);
1049
+ const more = button('ver completo', () => loadFullToolResult(entry, message), 'tool-more');
1050
+ more.hidden = true;
1051
+ card.append(head, out, more);
900
1052
  message.tools.appendChild(card);
1053
+ ensureToolsToggle(message);
901
1054
  scrollMessages(paneOf(message));
902
- return { card, out };
1055
+
1056
+ const entry = { card, out, glyph, argsEl, more, callId, full: false, truncated: false };
1057
+ card.addEventListener('toggle', () => {
1058
+ if (card.open && entry.truncated && !entry.full) loadFullToolResult(entry, message);
1059
+ });
1060
+ return entry;
903
1061
  }
904
1062
 
905
1063
  function addPermissionCard(message, data) {
@@ -957,6 +1115,7 @@ function userImages(content) {
957
1115
  }
958
1116
 
959
1117
  function renderStored(message, pane) {
1118
+ if (message.agent_id) return;
960
1119
  if (message.role === 'user') {
961
1120
  const bubble = addMessage('user', userText(message.content), pane);
962
1121
  if (message.id) {
@@ -972,16 +1131,31 @@ function renderStored(message, pane) {
972
1131
  bubble.wrap.dataset.messageId = message.id;
973
1132
  }
974
1133
  for (const call of message.tool_calls ?? []) {
975
- const tool = addToolCard(
976
- bubble,
977
- call.function?.name ?? call.name,
978
- call.function?.arguments ?? call.arguments ?? ''
979
- );
1134
+ const name = call.function?.name ?? call.name;
1135
+ const tool = addToolCard(bubble, name, parseToolArgs(call), { callId: call.id, open: false });
980
1136
  tool.card.classList.remove('running');
981
1137
  tool.card.classList.add('done');
982
- tool.card.querySelector('.tool-glyph').textContent = '✓';
1138
+ tool.glyph.textContent = '✓';
1139
+ if (call.id) toolCardIndex.set(call.id, tool);
983
1140
  }
984
- }
1141
+ } else if (message.role === 'tool') {
1142
+ const tool = toolCardIndex.get(message.tool_call_id);
1143
+ if (!tool) return;
1144
+ const failed = /^Error|^Permiso denegado/i.test(String(message.content));
1145
+ const { preview, truncated } = previewText(message.content);
1146
+ renderToolOut(tool.out, preview);
1147
+ tool.card.classList.remove('running');
1148
+ tool.card.classList.toggle('failed', failed);
1149
+ if (!failed) tool.card.classList.add('done');
1150
+ tool.glyph.textContent = failed ? '✗' : '✓';
1151
+ tool.truncated = truncated;
1152
+ if (truncated) tool.more.hidden = false;
1153
+ }
1154
+ }
1155
+
1156
+ function renderStoredMessages(messages, pane) {
1157
+ toolCardIndex.clear();
1158
+ for (const message of messages) renderStored(message, pane);
985
1159
  }
986
1160
 
987
1161
  function basename(p) {
@@ -1097,7 +1271,7 @@ async function removeProject(cwd, count) {
1097
1271
 
1098
1272
  /* --- session tabs --------------------------------------------------------- */
1099
1273
 
1100
- function registerSession(session, messages = [], tokens = { in: 0, out: 0 }) {
1274
+ function registerSession(session, messages = [], tokens = { in: 0, out: 0 }, running = false) {
1101
1275
  const id = session.id;
1102
1276
  if (state.panes.has(id)) return state.panes.get(id);
1103
1277
  const pane = document.createElement('div');
@@ -1115,10 +1289,11 @@ function registerSession(session, messages = [], tokens = { in: 0, out: 0 }) {
1115
1289
  };
1116
1290
  state.panes.set(id, record);
1117
1291
  state.tabOrder.push(id);
1118
- for (const message of messages) renderStored(message, pane);
1292
+ renderStoredMessages(messages, pane);
1119
1293
  if (!messages.length) {
1120
1294
  pane.appendChild(el('div', 'empty', `Sesión vacía${session.cwd ? ` en ${basename(session.cwd)}` : ''}.`));
1121
1295
  }
1296
+ if (running) attachRun(id, pane);
1122
1297
  return record;
1123
1298
  }
1124
1299
 
@@ -1163,7 +1338,7 @@ async function openTab(id) {
1163
1338
  return null;
1164
1339
  });
1165
1340
  if (!data) return;
1166
- registerSession(data.session, data.messages, data.tokens);
1341
+ registerSession(data.session, data.messages, data.tokens, data.running);
1167
1342
  activateSession(id);
1168
1343
  }
1169
1344
 
@@ -1244,7 +1419,7 @@ async function restoreTabs() {
1244
1419
  for (const id of ids) {
1245
1420
  if (state.panes.has(id)) continue;
1246
1421
  const data = await api(`/api/sessions/${id}`).catch(() => null);
1247
- if (data) registerSession(data.session, data.messages, data.tokens);
1422
+ if (data) registerSession(data.session, data.messages, data.tokens, data.running);
1248
1423
  }
1249
1424
  const active = saved?.active && state.panes.has(saved.active) ? saved.active : state.tabOrder[state.tabOrder.length - 1];
1250
1425
  if (active) activateSession(active);
@@ -1259,6 +1434,213 @@ $('#new-session').addEventListener('click', () => {
1259
1434
  const INIT_PROMPT =
1260
1435
  'Analizá este proyecto (estructura, stack, convenciones, comandos de build/test) y creá o actualizá un archivo AGENTS.md en la raíz con instrucciones claras para futuros agentes. Inspeccioná archivos reales antes de escribir.';
1261
1436
 
1437
+ function createChatTurn(pane, sessionKey) {
1438
+ const turn = {
1439
+ pane,
1440
+ sessionKey,
1441
+ active: null,
1442
+ sawText: false,
1443
+ lastTool: null,
1444
+ startNewOnText: false,
1445
+ agents: new Map(),
1446
+ turnMode: modeSelect.value || 'agent',
1447
+ assistant() {
1448
+ if (!turn.active) {
1449
+ turn.active = addMessage('assistant', '', turn.pane);
1450
+ setMessageMode(turn.active, turn.turnMode);
1451
+ }
1452
+ return turn.active;
1453
+ },
1454
+ flowTarget() {
1455
+ if (turn.startNewOnText) {
1456
+ turn.active = addMessage('assistant', '', turn.pane);
1457
+ setMessageMode(turn.active, turn.turnMode);
1458
+ turn.startNewOnText = false;
1459
+ setActivity(turn.active, 'thinking');
1460
+ }
1461
+ return turn.assistant();
1462
+ },
1463
+ };
1464
+ return turn;
1465
+ }
1466
+
1467
+ function addAgentCard(message, data) {
1468
+ const card = el('details', 'agent-card running');
1469
+ const head = el('summary', 'agent-head');
1470
+ const glyph = el('span', 'agent-glyph', '⏺');
1471
+ const title = el('span', 'agent-title', `subagente · ${data.description ?? 'tarea'}`);
1472
+ const status = el('span', 'agent-status', data.mode === 'plan' ? 'solo lectura' : 'agente');
1473
+ head.append(glyph, title, status);
1474
+ const body = el('div', 'agent-body');
1475
+ const reasoning = el('details', 'reasoning');
1476
+ reasoning.appendChild(el('summary', '', 'razonamiento'));
1477
+ const reasoningEl = el('div', 'body');
1478
+ reasoning.appendChild(reasoningEl);
1479
+ reasoning.hidden = true;
1480
+ const textEl = el('div', 'agent-text');
1481
+ const toolsEl = el('div', 'agent-tools');
1482
+ body.append(reasoning, textEl, toolsEl);
1483
+ card.append(head, body);
1484
+ message.tools.appendChild(card);
1485
+ ensureToolsToggle(message);
1486
+ scrollMessages(paneOf(message));
1487
+ const pseudo = { tools: toolsEl, bubble: card, toolsToggle: null };
1488
+ return {
1489
+ card,
1490
+ glyph,
1491
+ statusEl: status,
1492
+ textEl,
1493
+ reasoning,
1494
+ reasoningEl,
1495
+ toolsEl,
1496
+ message: pseudo,
1497
+ lastTool: null,
1498
+ sawText: false,
1499
+ };
1500
+ }
1501
+
1502
+ function handleAgentEvent(event, data, turn) {
1503
+ if (!data?.agentId) return false;
1504
+ const agent = turn.agents.get(data.agentId);
1505
+ if (!agent) return false;
1506
+ if (event === 'text') {
1507
+ agent.sawText = true;
1508
+ agent.textEl.innerHTML = renderMarkdown(data.full);
1509
+ scrollMessages(paneOf(agent.message));
1510
+ } else if (event === 'reasoning') {
1511
+ agent.reasoning.hidden = false;
1512
+ if (!agent.sawText) agent.reasoningEl.textContent = data.full;
1513
+ } else if (event === 'tool_call') {
1514
+ agent.lastTool = addToolCard(agent.message, data.name, data.args, { callId: data.id });
1515
+ } else if (event === 'tool_result') {
1516
+ if (agent.lastTool) {
1517
+ const failed = data.failed ?? /^Error|^Permiso denegado/i.test(String(data.preview ?? data.result));
1518
+ renderToolOut(agent.lastTool.out, data.preview ?? data.result);
1519
+ agent.lastTool.card.classList.remove('running');
1520
+ agent.lastTool.card.classList.toggle('failed', failed);
1521
+ if (!failed) agent.lastTool.card.classList.add('done');
1522
+ agent.lastTool.glyph.textContent = failed ? '✗' : '✓';
1523
+ if (data.truncated) {
1524
+ agent.lastTool.truncated = true;
1525
+ agent.lastTool.more.hidden = false;
1526
+ }
1527
+ }
1528
+ } else {
1529
+ return false;
1530
+ }
1531
+ return true;
1532
+ }
1533
+
1534
+ function handleChatEvent(event, data, turn) {
1535
+ if (handleAgentEvent(event, data, turn)) return;
1536
+ if (event === 'agent_start') {
1537
+ turn.agents.set(data.agentId, addAgentCard(turn.assistant(), data));
1538
+ } else if (event === 'agent_end') {
1539
+ const agent = turn.agents.get(data.agentId);
1540
+ if (agent) {
1541
+ agent.card.classList.remove('running');
1542
+ agent.card.classList.add(data.status === 'error' ? 'failed' : 'done');
1543
+ agent.glyph.textContent = data.status === 'error' ? '✗' : '✓';
1544
+ agent.statusEl.textContent = data.status;
1545
+ if (data.content && !agent.sawText) agent.textEl.textContent = data.content;
1546
+ }
1547
+ } else if (event === 'start') {
1548
+ if (data.cwd) setCwd(data.cwd);
1549
+ if (data.mode) turn.turnMode = data.mode;
1550
+ if (turn.active) setMessageMode(turn.active, turn.turnMode);
1551
+ } else if (event === 'text') {
1552
+ turn.sawText = true;
1553
+ const target = turn.flowTarget();
1554
+ setText(target, data.full);
1555
+ setActivity(target, 'thinking');
1556
+ } else if (event === 'reasoning') {
1557
+ const target = turn.flowTarget();
1558
+ if (!turn.sawText) ensureReasoning(target).textContent = data.full;
1559
+ setActivity(target, 'thinking');
1560
+ } else if (event === 'tools') {
1561
+ turn.startNewOnText = true;
1562
+ } else if (event === 'tool_call') {
1563
+ const target = turn.assistant();
1564
+ setActivity(target, 'working');
1565
+ turn.lastTool = addToolCard(target, data.name, data.args, { callId: data.id });
1566
+ } else if (event === 'tool_result') {
1567
+ setActivity(turn.assistant(), 'thinking');
1568
+ if (turn.lastTool) {
1569
+ const failed = data.failed ?? /^Error|^Permiso denegado/i.test(String(data.preview ?? data.result));
1570
+ renderToolOut(turn.lastTool.out, data.preview ?? data.result);
1571
+ turn.lastTool.card.classList.remove('running');
1572
+ turn.lastTool.card.classList.toggle('failed', failed);
1573
+ if (!failed) turn.lastTool.card.classList.add('done');
1574
+ turn.lastTool.glyph.textContent = failed ? '✗' : '✓';
1575
+ if (data.truncated) {
1576
+ turn.lastTool.truncated = true;
1577
+ turn.lastTool.more.hidden = false;
1578
+ }
1579
+ }
1580
+ } else if (event === 'permission') {
1581
+ setActivity(turn.assistant(), 'waiting');
1582
+ addPermissionCard(turn.assistant(), data);
1583
+ } else if (event === 'permission_resolved') {
1584
+ setActivity(turn.assistant(), 'thinking');
1585
+ const entry = state.permissionCards.get(data.askId);
1586
+ if (entry && entry.verdict.textContent === 'requiere aprobación') {
1587
+ entry.card.classList.add('collapsed', data.allow ? 'approved' : 'denied');
1588
+ entry.card.classList.remove('pending');
1589
+ entry.card.querySelector('.perm-icon').textContent = data.allow ? '✓' : '✗';
1590
+ entry.verdict.textContent = data.allow ? 'aprobado' : 'denegado (sin respuesta)';
1591
+ for (const btn of Object.values(entry.buttons)) btn.disabled = true;
1592
+ }
1593
+ } else if (event === 'stall') {
1594
+ if (!turn.sawText) setText(turn.assistant(), `⚠ ${data.seconds} s sin respuesta del modelo (proveedor caído o muy lento). Sigo esperando...`);
1595
+ } else if (event === 'retry') {
1596
+ if (!turn.sawText) setText(turn.assistant(), `↻ reintentando (${data.attempt}) en ${Math.round((data.delayMs || 0) / 1000)} s...`);
1597
+ } else if (event === 'trim') {
1598
+ turn.assistant().tools.appendChild(el('div', 'chip', `✂ contexto recortado (${data.trimmed} resultados, ${data.dropped} mensajes)`));
1599
+ } else if (event === 'warning') {
1600
+ turn.assistant().tools.appendChild(el('div', 'chip', `⚠ ${data.message}`));
1601
+ } else if (event === 'error') {
1602
+ setActivity(turn.active, null);
1603
+ turn.assistant().tools.appendChild(el('div', 'chip err', `✗ ${data.message}`));
1604
+ } else if (event === 'done') {
1605
+ setActivity(turn.active, null);
1606
+ if (!data.content && !turn.sawText) setText(turn.assistant(), '(sin texto)');
1607
+ updateStatus({ rounds: data.rounds, stopReason: data.stopReason });
1608
+ addTokens(data.tokens);
1609
+ } else if (event === 'aborted') {
1610
+ setActivity(turn.active, null);
1611
+ setText(turn.assistant(), '⏹ cancelado.');
1612
+ }
1613
+ }
1614
+
1615
+ /**
1616
+ * Se engancha a un run en curso: re-renderiza el historial persistido y
1617
+ * reproduce los eventos del turno actual (asi el progreso no se pierde si el
1618
+ * telefono bloqueo la pantalla o se corto la red).
1619
+ */
1620
+ async function attachRun(sessionKey, pane) {
1621
+ const record = state.panes.get(sessionKey);
1622
+ if (record?.working) return;
1623
+ await renderPane(sessionKey);
1624
+ const controller = new AbortController();
1625
+ controllers.set(sessionKey, controller);
1626
+ if (record) setWorking(sessionKey, true);
1627
+ const turn = createChatTurn(pane, sessionKey);
1628
+ try {
1629
+ await streamSse(
1630
+ `/api/sessions/${sessionKey}/stream?from=0`,
1631
+ { signal: controller.signal },
1632
+ (event, data) => handleChatEvent(event, data, turn)
1633
+ );
1634
+ } catch {
1635
+ // si falla la reconexion, el historial persistido queda visible
1636
+ } finally {
1637
+ controllers.delete(sessionKey);
1638
+ if (state.panes.get(sessionKey) === record) setWorking(sessionKey, false);
1639
+ setActivity(turn.active, null);
1640
+ refreshSessions();
1641
+ }
1642
+ }
1643
+
1262
1644
  async function sendMessage() {
1263
1645
  const current = state.panes.get(state.sessionId);
1264
1646
  if (current?.working) return;
@@ -1297,24 +1679,13 @@ async function sendMessage() {
1297
1679
  chatInput.value = '';
1298
1680
  state.attachments = [];
1299
1681
  renderAttachments();
1300
- let active = addMessage('assistant', '', pane);
1682
+
1683
+ const turn = createChatTurn(pane, sessionKey);
1684
+ turn.active = addMessage('assistant', '', pane);
1685
+ setMessageMode(turn.active, turn.turnMode);
1301
1686
  const controller = new AbortController();
1302
1687
  controllers.set(sessionKey, controller);
1303
1688
  setWorking(sessionKey, true);
1304
- let sawText = false;
1305
- let lastTool = null;
1306
- let startNewOnText = false;
1307
- let turnMode = modeSelect.value || 'agent';
1308
-
1309
- const flowTarget = () => {
1310
- if (startNewOnText) {
1311
- active = addMessage('assistant', '', pane);
1312
- setMessageMode(active, turnMode);
1313
- startNewOnText = false;
1314
- setActivity(active, 'thinking');
1315
- }
1316
- return active;
1317
- };
1318
1689
 
1319
1690
  try {
1320
1691
  await streamPost(
@@ -1332,74 +1703,27 @@ async function sendMessage() {
1332
1703
  return context ? Math.floor(context * 0.8) : undefined;
1333
1704
  })(),
1334
1705
  },
1335
- (event, data) => {
1336
- if (event === 'start') {
1337
- if (data.cwd) setCwd(data.cwd);
1338
- if (data.mode) turnMode = data.mode;
1339
- setMessageMode(active, turnMode);
1340
- } else if (event === 'text') {
1341
- sawText = true;
1342
- const target = flowTarget();
1343
- setText(target, data.full);
1344
- setActivity(target, 'thinking');
1345
- } else if (event === 'reasoning') {
1346
- const target = flowTarget();
1347
- if (!sawText) ensureReasoning(target).textContent = data.full;
1348
- setActivity(target, 'thinking');
1349
- } else if (event === 'tools') {
1350
- startNewOnText = true;
1351
- } else if (event === 'tool_call') {
1352
- setActivity(active, 'working');
1353
- lastTool = addToolCard(active, data.name, JSON.stringify(data.args));
1354
- } else if (event === 'tool_result') {
1355
- setActivity(active, 'thinking');
1356
- if (lastTool) {
1357
- const failed = /^Error|^Permiso denegado/i.test(String(data.result));
1358
- renderToolOut(lastTool.out, truncate(data.result));
1359
- lastTool.card.classList.remove('running');
1360
- lastTool.card.classList.add(failed ? 'failed' : 'done');
1361
- lastTool.card.querySelector('.tool-glyph').textContent = failed ? '✗' : '✓';
1362
- }
1363
- } else if (event === 'permission') {
1364
- setActivity(active, 'waiting');
1365
- addPermissionCard(active, data);
1366
- } else if (event === 'permission_resolved') {
1367
- setActivity(active, 'thinking');
1368
- const entry = state.permissionCards.get(data.askId);
1369
- if (entry && entry.verdict.textContent === 'requiere aprobación') {
1370
- entry.card.classList.add('collapsed', data.allow ? 'approved' : 'denied');
1371
- entry.card.classList.remove('pending');
1372
- entry.card.querySelector('.perm-icon').textContent = data.allow ? '✓' : '✗';
1373
- entry.verdict.textContent = data.allow ? 'aprobado' : 'denegado (sin respuesta)';
1374
- for (const btn of Object.values(entry.buttons)) btn.disabled = true;
1375
- }
1376
- } else if (event === 'stall') {
1377
- if (!sawText) setText(active, `⚠ ${data.seconds} s sin respuesta del modelo (proveedor caído o muy lento). Sigo esperando...`);
1378
- } else if (event === 'retry') {
1379
- if (!sawText) setText(active, `↻ reintentando (${data.attempt}) en ${Math.round((data.delayMs || 0) / 1000)} s...`);
1380
- } else if (event === 'trim') {
1381
- active.tools.appendChild(el('div', 'chip', `✂ contexto recortado (${data.trimmed} resultados, ${data.dropped} mensajes)`));
1382
- } else if (event === 'warning') {
1383
- active.tools.appendChild(el('div', 'chip', `⚠ ${data.message}`));
1384
- } else if (event === 'error') {
1385
- setActivity(active, null);
1386
- active.tools.appendChild(el('div', 'chip err', `✗ ${data.message}`));
1387
- } else if (event === 'done') {
1388
- setActivity(active, null);
1389
- if (!data.content && !sawText) setText(active, '(sin texto)');
1390
- updateStatus({ rounds: data.rounds, stopReason: data.stopReason });
1391
- addTokens(data.tokens);
1392
- }
1393
- },
1706
+ (event, data) => handleChatEvent(event, data, turn),
1394
1707
  controller.signal
1395
1708
  );
1396
1709
  } catch (error) {
1397
- setActivity(active, null);
1398
- if (error.name === 'AbortError') setText(active, '⏹ cancelado.');
1399
- else setText(active, `Error: ${error.message}`);
1710
+ if (error.name === 'AbortError') {
1711
+ setActivity(turn.active, null);
1712
+ setText(turn.assistant(), '⏹ cancelado.');
1713
+ } else {
1714
+ const detail = await api(`/api/sessions/${sessionKey}`).catch(() => null);
1715
+ if (detail?.running) {
1716
+ controllers.delete(sessionKey);
1717
+ setWorking(sessionKey, false);
1718
+ await attachRun(sessionKey, pane);
1719
+ } else {
1720
+ setActivity(turn.active, null);
1721
+ setText(turn.assistant(), `Error: ${error.message}`);
1722
+ }
1723
+ }
1400
1724
  } finally {
1401
1725
  controllers.delete(sessionKey);
1402
- setActivity(active, null);
1726
+ setActivity(turn.active, null);
1403
1727
  setWorking(sessionKey, false);
1404
1728
  refreshSessions();
1405
1729
  }
@@ -1407,7 +1731,9 @@ async function sendMessage() {
1407
1731
 
1408
1732
  if (stopBtn) {
1409
1733
  stopBtn.addEventListener('click', () => {
1410
- const controller = controllers.get(state.sessionId);
1734
+ const id = state.sessionId;
1735
+ if (id) api(`/api/sessions/${id}/stop`, { method: 'POST' }).catch(() => {});
1736
+ const controller = controllers.get(id);
1411
1737
  if (controller) controller.abort();
1412
1738
  });
1413
1739
  }
@@ -33,7 +33,7 @@
33
33
  </head>
34
34
  <body>
35
35
  <header class="topbar">
36
- <div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.5.1</span></div>
36
+ <div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.6.0</span></div>
37
37
  <nav class="tabs">
38
38
  <button class="tab active" data-tab="chat">chat</button>
39
39
  <button class="tab" data-tab="providers">proveedores</button>