@blockrun/franklin 3.9.6 → 3.10.1

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.
@@ -273,6 +273,63 @@ a:hover { text-decoration:underline; }
273
273
  .tab.active { display:block; }
274
274
  .empty { color:var(--text-dim); text-align:center; padding:56px 24px; font-size:13px; }
275
275
 
276
+ /* ── Tasks ── */
277
+ .tasks-toolbar { display:flex; align-items:center; gap:10px; margin-bottom:12px; }
278
+ .tasks-table {
279
+ display:flex; flex-direction:column; gap:4px;
280
+ }
281
+ .task-row {
282
+ display:grid; grid-template-columns:140px 1fr 110px 90px 92px;
283
+ gap:12px; align-items:center;
284
+ background:oklch(0.19 0.006 286 / 75%); border:1px solid var(--border); border-radius:8px;
285
+ padding:11px 14px; cursor:pointer; transition:all .15s ease;
286
+ backdrop-filter:blur(8px); -webkit-backdrop-filter:blur(8px);
287
+ }
288
+ .task-row:hover { background:var(--bg-card-hover); border-color:var(--border-strong); }
289
+ .task-row .runid { font-family:var(--mono); font-size:11px; color:var(--text-muted); }
290
+ .task-row .label { font-size:13px; color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
291
+ .task-row .age { font-family:var(--mono); font-size:11px; color:var(--text-dim); }
292
+ .task-row .actions { text-align:right; }
293
+ .task-row .cancel-err { grid-column:1 / -1; color:var(--danger); font-size:11px; font-family:var(--mono); padding-top:4px; }
294
+ .task-status {
295
+ display:inline-block; font-size:9px; font-family:var(--mono); font-weight:700;
296
+ padding:3px 8px; border-radius:5px; text-transform:uppercase; letter-spacing:0.6px;
297
+ }
298
+ .task-status.succeeded { background:oklch(0.72 0.17 150 / 14%); color:var(--success); }
299
+ .task-status.running { background:oklch(0.68 0.16 260 / 16%); color:var(--brand); }
300
+ .task-status.queued { background:oklch(1 0 0 / 6%); color:var(--text-dim); }
301
+ .task-status.failed,
302
+ .task-status.lost { background:oklch(0.65 0.20 25 / 16%); color:var(--danger); }
303
+ .task-status.cancelled { background:oklch(0.78 0.14 85 / 14%); color:var(--warning); }
304
+ .task-status.timed_out { background:oklch(0.65 0.20 25 / 16%); color:var(--danger); }
305
+
306
+ .task-detail {
307
+ background:var(--bg-card); border:1px solid var(--border); border-radius:var(--radius);
308
+ padding:18px; margin-bottom:14px;
309
+ }
310
+ .task-detail h4 { font-size:11px; color:var(--text-dim); text-transform:uppercase; letter-spacing:0.8px; font-weight:600; margin:14px 0 6px; }
311
+ .task-detail .top { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; }
312
+ .task-detail .top .title { font-size:14px; font-weight:600; }
313
+ .task-detail-meta {
314
+ display:grid; grid-template-columns:max-content 1fr; column-gap:14px; row-gap:4px;
315
+ font-family:var(--mono); font-size:11.5px; color:var(--text-muted);
316
+ }
317
+ .task-detail-meta .k { color:var(--text-dim); }
318
+ .task-detail-meta .v { word-break:break-all; }
319
+ .task-events { display:flex; flex-direction:column; gap:4px; font-family:var(--mono); font-size:11.5px; }
320
+ .task-event { display:grid; grid-template-columns:90px 130px 1fr; gap:10px; padding:3px 0; color:var(--text-muted); border-bottom:1px solid var(--border); }
321
+ .task-event:last-child { border:none; }
322
+ .task-event .kind { font-weight:600; color:var(--text); }
323
+ .task-log-footer { font-size:11px; color:var(--warning); margin:8px 0 4px; font-family:var(--mono); }
324
+ .task-log {
325
+ font-family:var(--mono); font-size:11.5px; color:var(--text-muted);
326
+ background:oklch(0 0 0 / 35%); border:1px solid var(--border);
327
+ border-radius:8px; padding:10px 12px; max-height:400px;
328
+ overflow-y:auto; white-space:pre-wrap; word-break:break-all;
329
+ line-height:1.5;
330
+ }
331
+ .task-detail-actions { display:flex; gap:8px; margin-top:14px; }
332
+
276
333
  /* ── Wallet page ── */
277
334
  .chain-switcher {
278
335
  display:inline-flex; padding:3px; gap:2px;
@@ -402,6 +459,10 @@ a:hover { text-decoration:underline; }
402
459
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
403
460
  Sessions
404
461
  </button>
462
+ <button class="nav-item" data-tab="tasks">
463
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
464
+ Tasks
465
+ </button>
405
466
  <button class="nav-item" data-tab="learnings">
406
467
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
407
468
  Learnings
@@ -567,8 +628,22 @@ a:hover { text-decoration:underline; }
567
628
  <p>Browse past conversations</p>
568
629
  </div>
569
630
  <input class="search-box" id="session-search" placeholder="Search sessions..." />
570
- <div class="session-list" id="session-list"></div>
571
631
  <div class="session-detail" id="session-detail" style="display:none"></div>
632
+ <div class="session-list" id="session-list"></div>
633
+ </div>
634
+
635
+ <!-- Tasks -->
636
+ <div class="tab" id="tab-tasks">
637
+ <div class="content-header">
638
+ <h2>Tasks</h2>
639
+ <p>Detached background work — long builds, runs, jobs.</p>
640
+ </div>
641
+ <div class="tasks-toolbar">
642
+ <button class="btn" id="tasks-refresh-btn">Refresh</button>
643
+ <span id="tasks-summary" style="font-size:12px;color:var(--text-dim);"></span>
644
+ </div>
645
+ <div class="task-detail" id="task-detail" style="display:none"></div>
646
+ <div class="tasks-table" id="tasks-list"></div>
572
647
  </div>
573
648
 
574
649
  <!-- Markets -->
@@ -639,20 +714,41 @@ a:hover { text-decoration:underline; }
639
714
  </div>
640
715
 
641
716
  <script>
642
- // Tab switching
717
+ // Tab switching — supports URL hash (e.g. #tasks) for deep links.
718
+ // Emits a 'tab:activated' / 'tab:deactivated' event so per-tab modules
719
+ // can start/stop their pollers without coupling to the dispatcher.
720
+ let _activeTab = 'overview';
721
+ function activateTab(name) {
722
+ if (!document.getElementById('tab-' + name)) name = 'overview';
723
+ if (name === _activeTab) return;
724
+ const prev = _activeTab;
725
+ document.querySelectorAll('.nav-item').forEach(b => b.classList.toggle('active', b.dataset.tab === name));
726
+ document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.id === 'tab-' + name));
727
+ _activeTab = name;
728
+ document.dispatchEvent(new CustomEvent('tab:deactivated', { detail: { name: prev } }));
729
+ document.dispatchEvent(new CustomEvent('tab:activated', { detail: { name } }));
730
+ }
643
731
  document.querySelectorAll('.nav-item').forEach(btn => {
644
732
  btn.addEventListener('click', () => {
645
- document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
646
- document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
647
- btn.classList.add('active');
648
- document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
733
+ const name = btn.dataset.tab;
734
+ if (history.replaceState) history.replaceState(null, '', '#' + name);
735
+ activateTab(name);
649
736
  });
650
737
  });
738
+ window.addEventListener('hashchange', () => {
739
+ const name = (location.hash || '').replace(/^#/, '');
740
+ if (name) activateTab(name);
741
+ });
651
742
 
652
743
  const api = (path) => fetch('/api/' + path).then(r => r.json()).catch(() => null);
653
744
  const usd = (n) => '$' + (n || 0).toFixed(4);
654
745
  const usdBig = (n) => '$' + (n || 0).toFixed(2);
655
- const esc = (s) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
746
+ const esc = (s) => String(s ?? '')
747
+ .replace(/&/g, '&amp;')
748
+ .replace(/</g, '&lt;')
749
+ .replace(/>/g, '&gt;')
750
+ .replace(/"/g, '&quot;')
751
+ .replace(/'/g, '&#39;');
656
752
 
657
753
  async function loadOverview() {
658
754
  const [wallet, stats, insights] = await Promise.all([
@@ -719,27 +815,57 @@ async function loadOverview() {
719
815
 
720
816
  async function loadSessions() {
721
817
  const sessions = await api('sessions');
818
+ clearSessionDetail();
722
819
  if (!sessions || sessions.length === 0) {
723
820
  document.getElementById('session-list').innerHTML = '<div class="empty">No sessions yet</div>';
724
821
  return;
725
822
  }
726
- document.getElementById('session-list').innerHTML = sessions.slice(0, 50).map(s =>
823
+ document.getElementById('session-list').innerHTML = sessions.slice(0, 50).map(renderSessionRow).join('');
824
+ attachSessionClickHandlers();
825
+ }
826
+
827
+ function renderSessionRow(s) {
828
+ return (
727
829
  '<div class="session-item" data-id="' + esc(s.id) + '">' +
728
830
  '<div class="title">' + esc(s.model || 'unknown') + ' &mdash; ' + s.messageCount + ' messages</div>' +
729
831
  '<div class="meta">' + new Date(s.createdAt).toLocaleString() + ' &middot; ' + esc((s.workDir || '').split('/').pop()) + '</div>' +
730
832
  '</div>'
731
- ).join('');
732
- document.querySelectorAll('.session-item').forEach(el => {
833
+ );
834
+ }
835
+
836
+ function renderSessionSearchRow(r) {
837
+ const s = r.session || {};
838
+ const id = s.id || r.sessionId || '';
839
+ const model = s.model || 'unknown';
840
+ const score = Number.isFinite(r.score) ? r.score.toFixed(2) : '0.00';
841
+ return (
842
+ '<div class="session-item" data-id="' + esc(id) + '">' +
843
+ '<div class="title">' + esc(r.snippet || '(no snippet)') + '</div>' +
844
+ '<div class="meta">' + esc(model) + ' &middot; ' + esc(id) + ' &middot; score: ' + score + '</div>' +
845
+ '</div>'
846
+ );
847
+ }
848
+
849
+ function clearSessionDetail() {
850
+ const detail = document.getElementById('session-detail');
851
+ detail.style.display = 'none';
852
+ detail.innerHTML = '';
853
+ }
854
+
855
+ function attachSessionClickHandlers() {
856
+ document.querySelectorAll('.session-item[data-id]').forEach(el => {
733
857
  el.addEventListener('click', async () => {
858
+ if (!el.dataset.id) return;
734
859
  const history = await api('sessions/' + encodeURIComponent(el.dataset.id));
735
860
  if (!history) return;
736
861
  const detail = document.getElementById('session-detail');
737
862
  detail.style.display = 'block';
738
863
  detail.innerHTML = history.map(m => {
739
864
  const role = m.role || 'system';
740
- let text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content).slice(0, 500);
865
+ let text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? null).slice(0, 500);
741
866
  return '<div class="msg ' + role + '"><div class="role">' + role + '</div><pre>' + esc(text) + '</pre></div>';
742
867
  }).join('');
868
+ detail.scrollIntoView({ behavior: 'smooth', block: 'start' });
743
869
  });
744
870
  });
745
871
  }
@@ -751,16 +877,13 @@ document.getElementById('session-search').addEventListener('input', (e) => {
751
877
  const q = e.target.value.trim();
752
878
  if (!q) { loadSessions(); return; }
753
879
  const results = await api('sessions/search?q=' + encodeURIComponent(q));
880
+ clearSessionDetail();
754
881
  if (!results || results.length === 0) {
755
882
  document.getElementById('session-list').innerHTML = '<div class="empty">No results</div>';
756
883
  return;
757
884
  }
758
- document.getElementById('session-list').innerHTML = results.map(r =>
759
- '<div class="session-item">' +
760
- '<div class="title">' + esc(r.snippet) + '</div>' +
761
- '<div class="meta">' + esc(r.sessionId) + ' &middot; score: ' + r.score.toFixed(2) + '</div>' +
762
- '</div>'
763
- ).join('');
885
+ document.getElementById('session-list').innerHTML = results.map(renderSessionSearchRow).join('');
886
+ attachSessionClickHandlers();
764
887
  }, 300);
765
888
  });
766
889
 
@@ -878,15 +1001,24 @@ async function loadWallet() {
878
1001
  solanaBtn.classList.toggle('active', w.chain === 'solana');
879
1002
  }
880
1003
 
881
- // QR via server — never leak address to third parties
1004
+ // QR via server — never leak address to third parties.
1005
+ // Encode chain + USDC token in the QR payload so wallet apps land
1006
+ // directly on the right network/token instead of a bare address:
1007
+ // Base → EIP-681: ethereum:<USDC>@8453/transfer?address=<addr>
1008
+ // Solana → Solana Pay: solana:<addr>?spl-token=<USDC mint>
882
1009
  const qrBox = document.getElementById('wallet-qr');
883
1010
  const hint = document.getElementById('wallet-qr-hint');
884
1011
  if (addr && addr !== 'not set') {
885
- const svg = await fetch('/api/wallet/qr?data=' + encodeURIComponent(addr)).then(r => r.ok ? r.text() : null);
1012
+ const USDC_BASE = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
1013
+ const USDC_SOL_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
1014
+ const payload = w.chain === 'solana'
1015
+ ? 'solana:' + addr + '?spl-token=' + USDC_SOL_MINT
1016
+ : 'ethereum:' + USDC_BASE + '@8453/transfer?address=' + addr;
1017
+ const svg = await fetch('/api/wallet/qr?data=' + encodeURIComponent(payload)).then(r => r.ok ? r.text() : null);
886
1018
  qrBox.innerHTML = svg || '';
887
1019
  hint.textContent = w.chain === 'solana'
888
- ? 'Scan to send USDC (Solana SPL) to this address.'
889
- : 'Scan to send USDC on Base to this address.';
1020
+ ? 'Scan with a Solana wallet (Phantom, Solflare) to send USDC SPL.'
1021
+ : 'Scan with an EVM wallet (MetaMask, Coinbase) to send USDC on Base.';
890
1022
  } else {
891
1023
  qrBox.innerHTML = '';
892
1024
  hint.textContent = 'No wallet set yet — run: franklin setup';
@@ -1075,12 +1207,351 @@ async function loadAudit() {
1075
1207
  document.getElementById('audit-refresh')?.addEventListener('click', loadAudit);
1076
1208
  document.querySelector('[data-tab="audit"]')?.addEventListener('click', loadAudit);
1077
1209
 
1210
+ // ─── Tasks tab ───────────────────────────────────────────────────────────
1211
+ // Polls /api/tasks every 10s while the Tasks tab is active AND the page is
1212
+ // visible. Detail view layers a 2s log-tail poll using Range: bytes=N- on
1213
+ // top, stopping itself once the task hits a terminal status. No SSE — keeps
1214
+ // the panel server stateless and the wire format trivial.
1215
+ const TASK_TERMINAL = new Set(['succeeded', 'failed', 'timed_out', 'cancelled', 'lost']);
1216
+ const tasks = {
1217
+ pollTimer: null,
1218
+ logTimer: null,
1219
+ selected: null, // runId of currently-open detail
1220
+ logBytesShown: 0,
1221
+ cache: [], // last list snapshot (for finding the selected meta)
1222
+ finalLogFetched: false // ensures one final 200 fetch after task terminates
1223
+ };
1224
+
1225
+ function fmtAge(ts) {
1226
+ if (!ts) return '—';
1227
+ const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
1228
+ if (s < 60) return s + 's ago';
1229
+ const m = Math.round(s / 60);
1230
+ if (m < 60) return m + 'm ago';
1231
+ const h = Math.round(m / 60);
1232
+ if (h < 48) return h + 'h ago';
1233
+ return Math.round(h / 24) + 'd ago';
1234
+ }
1235
+ function fmtTime(ts) {
1236
+ if (!ts) return '—';
1237
+ return new Date(ts).toLocaleString();
1238
+ }
1239
+
1240
+ async function fetchTasks() {
1241
+ const data = await api('tasks');
1242
+ if (!data) {
1243
+ document.getElementById('tasks-list').innerHTML = '<div class="empty">API offline</div>';
1244
+ return;
1245
+ }
1246
+ const list = (data.tasks || []).slice().sort((a, b) => {
1247
+ const at = a.lastEventAt || a.createdAt || 0;
1248
+ const bt = b.lastEventAt || b.createdAt || 0;
1249
+ return bt - at;
1250
+ });
1251
+ tasks.cache = list;
1252
+ document.getElementById('tasks-summary').textContent = list.length + ' task' + (list.length === 1 ? '' : 's');
1253
+ if (list.length === 0) {
1254
+ document.getElementById('tasks-list').innerHTML =
1255
+ '<div class="empty">No tasks. Start one via the Detach agent tool, or manually with <code>franklin task ...</code>.</div>';
1256
+ } else {
1257
+ document.getElementById('tasks-list').innerHTML = list.map(renderTaskRow).join('');
1258
+ attachTaskRowHandlers();
1259
+ }
1260
+ // If a detail view is open, refresh its meta panel from the new snapshot
1261
+ if (tasks.selected) {
1262
+ const meta = list.find(t => t.runId === tasks.selected);
1263
+ if (meta) refreshTaskDetailMeta(meta);
1264
+ }
1265
+ }
1266
+
1267
+ function renderTaskRow(t) {
1268
+ const shortId = t.runId.slice(0, 12) + '…';
1269
+ const age = fmtAge(t.lastEventAt || t.createdAt);
1270
+ const showCancel = t.status === 'running' || t.status === 'queued';
1271
+ const cancelBtn = showCancel
1272
+ ? '<button class="btn btn-warn" data-cancel="' + esc(t.runId) + '">Cancel</button>'
1273
+ : '';
1274
+ return (
1275
+ '<div class="task-row" data-runid="' + esc(t.runId) + '">' +
1276
+ '<span class="runid">' + esc(shortId) + '</span>' +
1277
+ '<span class="label">' + esc(t.label || '(no label)') + '</span>' +
1278
+ '<span><span class="task-status ' + esc(t.status) + '">' + esc(t.status) + '</span></span>' +
1279
+ '<span class="age">' + esc(age) + '</span>' +
1280
+ '<span class="actions">' + cancelBtn + '</span>' +
1281
+ '</div>'
1282
+ );
1283
+ }
1284
+
1285
+ function attachTaskRowHandlers() {
1286
+ document.querySelectorAll('.task-row[data-runid]').forEach(el => {
1287
+ el.addEventListener('click', (ev) => {
1288
+ // Cancel button: handle and stop propagation so the row doesn't open detail
1289
+ const target = ev.target;
1290
+ if (target instanceof HTMLElement && target.dataset.cancel) {
1291
+ ev.stopPropagation();
1292
+ cancelTask(target.dataset.cancel, el);
1293
+ return;
1294
+ }
1295
+ openTaskDetail(el.dataset.runid);
1296
+ });
1297
+ });
1298
+ }
1299
+
1300
+ async function cancelTask(runId, rowEl) {
1301
+ if (!confirm('Cancel task ' + runId.slice(0, 12) + '…?\\n\\nFranklin will send SIGTERM to the running process.')) return;
1302
+ try {
1303
+ const r = await fetch('/api/tasks/' + encodeURIComponent(runId) + '/cancel', { method: 'POST' });
1304
+ const d = await r.json().catch(() => ({}));
1305
+ if (d && d.ok) {
1306
+ fetchTasks();
1307
+ } else {
1308
+ // Show inline error under the row
1309
+ const existing = rowEl && rowEl.querySelector('.cancel-err');
1310
+ if (existing) existing.remove();
1311
+ const err = document.createElement('div');
1312
+ err.className = 'cancel-err';
1313
+ err.textContent = 'Cancel failed: ' + (d && d.reason ? d.reason : 'unknown');
1314
+ if (rowEl) rowEl.appendChild(err);
1315
+ }
1316
+ } catch (err) {
1317
+ alert('Network error: ' + (err && err.message ? err.message : err));
1318
+ }
1319
+ }
1320
+
1321
+ async function openTaskDetail(runId) {
1322
+ tasks.selected = runId;
1323
+ tasks.logBytesShown = 0;
1324
+ tasks.finalLogFetched = false;
1325
+ const detail = document.getElementById('task-detail');
1326
+ detail.style.display = 'block';
1327
+ detail.innerHTML = '<div style="color:var(--text-dim);font-size:12px">Loading…</div>';
1328
+ detail.scrollIntoView({ behavior: 'smooth', block: 'start' });
1329
+
1330
+ const meta = await api('tasks/' + encodeURIComponent(runId));
1331
+ if (!meta) {
1332
+ detail.innerHTML = '<div class="empty">Task not found</div>';
1333
+ return;
1334
+ }
1335
+ renderTaskDetail(meta);
1336
+ await Promise.all([loadTaskEvents(runId), pollTaskLog(runId, /*initial*/ true)]);
1337
+ // Start log polling cadence (only if not terminal and visible)
1338
+ startLogPolling();
1339
+ }
1340
+
1341
+ function refreshTaskDetailMeta(meta) {
1342
+ // Update only the top metadata fields without resetting the log box.
1343
+ const top = document.getElementById('td-top');
1344
+ const metaBox = document.getElementById('td-meta');
1345
+ const cancelSlot = document.getElementById('td-cancel-slot');
1346
+ if (top) top.innerHTML = renderTaskDetailTop(meta);
1347
+ if (metaBox) metaBox.innerHTML = renderTaskDetailMetaRows(meta);
1348
+ if (cancelSlot) cancelSlot.innerHTML = renderTaskDetailCancelBtn(meta);
1349
+ attachTaskDetailButtonHandlers(meta);
1350
+ // If the task just hit terminal, surface the footer + stop polling
1351
+ if (TASK_TERMINAL.has(meta.status)) {
1352
+ const footer = document.getElementById('td-log-footer');
1353
+ if (footer && !footer.textContent) {
1354
+ footer.textContent = 'Final status: ' + meta.status + ' — log polling stopped.';
1355
+ }
1356
+ }
1357
+ }
1358
+
1359
+ function renderTaskDetailTop(t) {
1360
+ return (
1361
+ '<span class="title">' + esc(t.label || '(no label)') + '</span>' +
1362
+ '<span class="task-status ' + esc(t.status) + '">' + esc(t.status) + '</span>'
1363
+ );
1364
+ }
1365
+
1366
+ function renderTaskDetailMetaRows(t) {
1367
+ const rows = [
1368
+ ['runId', t.runId],
1369
+ ['command', t.command],
1370
+ ['workingDir', t.workingDir],
1371
+ ['pid', t.pid != null ? String(t.pid) : '—'],
1372
+ ['createdAt', fmtTime(t.createdAt)],
1373
+ ['startedAt', fmtTime(t.startedAt)],
1374
+ ['lastEventAt', fmtTime(t.lastEventAt)],
1375
+ ['endedAt', fmtTime(t.endedAt)],
1376
+ ];
1377
+ if (t.exitCode !== undefined) rows.push(['exitCode', String(t.exitCode)]);
1378
+ if (t.terminalSummary) rows.push(['terminalSummary', t.terminalSummary]);
1379
+ if (t.error) rows.push(['error', t.error]);
1380
+ return rows.map(([k, v]) =>
1381
+ '<span class="k">' + esc(k) + '</span><span class="v">' + esc(v == null ? '—' : v) + '</span>'
1382
+ ).join('');
1383
+ }
1384
+
1385
+ function renderTaskDetailCancelBtn(t) {
1386
+ if (TASK_TERMINAL.has(t.status)) return '';
1387
+ return '<button class="btn btn-warn" id="td-cancel-btn" data-runid="' + esc(t.runId) + '">Cancel</button>';
1388
+ }
1389
+
1390
+ function renderTaskDetail(t) {
1391
+ const detail = document.getElementById('task-detail');
1392
+ detail.innerHTML =
1393
+ '<div class="top" id="td-top">' + renderTaskDetailTop(t) + '</div>' +
1394
+ '<div class="task-detail-meta" id="td-meta">' + renderTaskDetailMetaRows(t) + '</div>' +
1395
+ '<h4>Recent events</h4>' +
1396
+ '<div class="task-events" id="td-events"><div style="color:var(--text-dim);font-size:11px">Loading…</div></div>' +
1397
+ '<h4>Log tail</h4>' +
1398
+ '<div class="task-log-footer" id="td-log-footer"></div>' +
1399
+ '<pre class="task-log" id="td-log"></pre>' +
1400
+ '<div class="task-detail-actions">' +
1401
+ '<span id="td-cancel-slot">' + renderTaskDetailCancelBtn(t) + '</span>' +
1402
+ '<button class="btn btn-ghost" id="td-close-btn">Close</button>' +
1403
+ '</div>';
1404
+ attachTaskDetailButtonHandlers(t);
1405
+ }
1406
+
1407
+ function attachTaskDetailButtonHandlers(t) {
1408
+ const closeBtn = document.getElementById('td-close-btn');
1409
+ if (closeBtn) closeBtn.onclick = closeTaskDetail;
1410
+ const cancelBtn = document.getElementById('td-cancel-btn');
1411
+ if (cancelBtn) cancelBtn.onclick = () => cancelTask(t.runId, null);
1412
+ }
1413
+
1414
+ function closeTaskDetail() {
1415
+ tasks.selected = null;
1416
+ stopLogPolling();
1417
+ const detail = document.getElementById('task-detail');
1418
+ detail.style.display = 'none';
1419
+ detail.innerHTML = '';
1420
+ }
1421
+
1422
+ async function loadTaskEvents(runId) {
1423
+ const data = await api('tasks/' + encodeURIComponent(runId) + '/events');
1424
+ const box = document.getElementById('td-events');
1425
+ if (!box) return;
1426
+ const events = (data && data.events ? data.events : [])
1427
+ .slice()
1428
+ .sort((a, b) => (b.at || 0) - (a.at || 0))
1429
+ .slice(0, 10);
1430
+ if (events.length === 0) {
1431
+ box.innerHTML = '<div style="color:var(--text-dim);font-size:11px">No events recorded.</div>';
1432
+ return;
1433
+ }
1434
+ box.innerHTML = events.map(e =>
1435
+ '<div class="task-event">' +
1436
+ '<span class="kind">' + esc(e.kind) + '</span>' +
1437
+ '<span>' + esc(fmtTime(e.at)) + '</span>' +
1438
+ '<span>' + esc(e.summary || '') + '</span>' +
1439
+ '</div>'
1440
+ ).join('');
1441
+ }
1442
+
1443
+ async function pollTaskLog(runId, initial) {
1444
+ const logEl = document.getElementById('td-log');
1445
+ if (!logEl) return;
1446
+ try {
1447
+ const headers = (!initial && tasks.logBytesShown > 0)
1448
+ ? { 'Range': 'bytes=' + tasks.logBytesShown + '-' }
1449
+ : {};
1450
+ const res = await fetch('/api/tasks/' + encodeURIComponent(runId) + '/log', { headers });
1451
+ if (res.status === 206) {
1452
+ const body = await res.text();
1453
+ if (body.length > 0) {
1454
+ logEl.textContent += body;
1455
+ tasks.logBytesShown += new Blob([body]).size;
1456
+ logEl.scrollTop = logEl.scrollHeight;
1457
+ }
1458
+ } else if (res.status === 200) {
1459
+ const body = await res.text();
1460
+ logEl.textContent = body;
1461
+ tasks.logBytesShown = new Blob([body]).size;
1462
+ logEl.scrollTop = logEl.scrollHeight;
1463
+ }
1464
+ } catch { /* network blip — next tick will retry */ }
1465
+ }
1466
+
1467
+ function startLogPolling() {
1468
+ stopLogPolling();
1469
+ if (!tasks.selected) return;
1470
+ tasks.logTimer = setInterval(async () => {
1471
+ if (!tasks.selected) { stopLogPolling(); return; }
1472
+ if (document.visibilityState !== 'visible') return;
1473
+ const runId = tasks.selected;
1474
+ const meta = tasks.cache.find(t => t.runId === runId);
1475
+ const status = meta ? meta.status : 'running';
1476
+ if (TASK_TERMINAL.has(status)) {
1477
+ // One final 200 fetch to flush, then stop.
1478
+ if (!tasks.finalLogFetched) {
1479
+ tasks.finalLogFetched = true;
1480
+ await pollTaskLog(runId, /*initial*/ true);
1481
+ }
1482
+ const footer = document.getElementById('td-log-footer');
1483
+ if (footer && !footer.textContent) footer.textContent = 'Final status: ' + status + ' — log polling stopped.';
1484
+ stopLogPolling();
1485
+ return;
1486
+ }
1487
+ pollTaskLog(runId, /*initial*/ false);
1488
+ }, 2000);
1489
+ }
1490
+
1491
+ function stopLogPolling() {
1492
+ if (tasks.logTimer) {
1493
+ clearInterval(tasks.logTimer);
1494
+ tasks.logTimer = null;
1495
+ }
1496
+ }
1497
+
1498
+ function startTasksPolling() {
1499
+ stopTasksPolling();
1500
+ fetchTasks();
1501
+ tasks.pollTimer = setInterval(() => {
1502
+ if (document.visibilityState === 'visible') fetchTasks();
1503
+ }, 10000);
1504
+ }
1505
+
1506
+ function stopTasksPolling() {
1507
+ if (tasks.pollTimer) {
1508
+ clearInterval(tasks.pollTimer);
1509
+ tasks.pollTimer = null;
1510
+ }
1511
+ }
1512
+
1513
+ document.addEventListener('tab:activated', (e) => {
1514
+ if (e.detail && e.detail.name === 'tasks') {
1515
+ startTasksPolling();
1516
+ if (tasks.selected) startLogPolling();
1517
+ }
1518
+ });
1519
+ document.addEventListener('tab:deactivated', (e) => {
1520
+ if (e.detail && e.detail.name === 'tasks') {
1521
+ stopTasksPolling();
1522
+ stopLogPolling();
1523
+ }
1524
+ });
1525
+ document.addEventListener('visibilitychange', () => {
1526
+ const visible = document.visibilityState === 'visible';
1527
+ if (_activeTab === 'tasks') {
1528
+ if (visible) {
1529
+ startTasksPolling();
1530
+ if (tasks.selected) startLogPolling();
1531
+ } else {
1532
+ stopTasksPolling();
1533
+ stopLogPolling();
1534
+ }
1535
+ }
1536
+ });
1537
+
1538
+ document.getElementById('tasks-refresh-btn')?.addEventListener('click', fetchTasks);
1539
+
1078
1540
  loadOverview();
1079
1541
  loadSessions();
1080
1542
  loadMarkets();
1081
1543
  loadLearnings();
1082
1544
  loadWallet();
1083
1545
  document.querySelector('[data-tab="markets"]')?.addEventListener('click', loadMarkets);
1546
+
1547
+ // Honor URL hash on initial load (e.g. /#tasks deep link)
1548
+ {
1549
+ const initialHash = (location.hash || '').replace(/^#/, '');
1550
+ if (initialHash && initialHash !== 'overview' && document.getElementById('tab-' + initialHash)) {
1551
+ activateTab(initialHash);
1552
+ }
1553
+ }
1554
+
1084
1555
  setInterval(() => api('wallet').then(w => {
1085
1556
  if (w) {
1086
1557
  document.getElementById('balance').textContent = usdBig(w.balance) + ' USDC';