a2acalling 0.6.57 → 0.6.59

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.
@@ -214,13 +214,13 @@ function formatUpdaterState(stateValue) {
214
214
  return state.replaceAll('_', ' ');
215
215
  }
216
216
 
217
- function updaterPillClass(stateValue) {
217
+ function badgeVariant(stateValue) {
218
218
  const state = String(stateValue || '').trim();
219
- if (state === 'failed') return 'err';
219
+ if (state === 'failed') return 'danger';
220
220
  if (state === 'waiting_for_safe_restart' || state === 'checking' || state === 'downloading' || state === 'applying' || state === 'restarting') {
221
- return 'warn';
221
+ return 'warning';
222
222
  }
223
- return 'ok';
223
+ return 'success';
224
224
  }
225
225
 
226
226
  async function copyText(value) {
@@ -250,36 +250,36 @@ async function copyText(value) {
250
250
  }
251
251
 
252
252
  function bindTabs() {
253
- const activateTab = (tab, options = {}) => {
254
- const target = String(tab || '').replace(/^#/, '').trim();
255
- if (!target) return false;
256
- const btn = Array.from(document.querySelectorAll('.tab')).find(b => b.dataset.tab === target);
257
- const panel = document.getElementById(`tab-${target}`);
258
- if (!btn || !panel) return false;
259
-
260
- document.querySelectorAll('.tab').forEach(b => b.classList.remove('is-active'));
261
- document.querySelectorAll('.panel').forEach(p => p.classList.remove('is-active'));
262
- btn.classList.add('is-active');
263
- panel.classList.add('is-active');
264
-
265
- if (options.updateHash) {
266
- try { window.location.hash = target; } catch (err) {}
267
- }
268
- return true;
269
- };
253
+ const tabGroup = document.getElementById('main-tabs');
254
+ if (!tabGroup) return;
270
255
 
271
- document.querySelectorAll('.tab').forEach(btn => {
272
- btn.addEventListener('click', () => {
273
- activateTab(btn.dataset.tab, { updateHash: true });
274
- });
256
+ tabGroup.addEventListener('sl-tab-show', (e) => {
257
+ const tabName = e.detail.name;
258
+ try { window.location.hash = tabName; } catch (err) {}
259
+ if (typeof onTabSwitch === 'function') onTabSwitch(tabName);
275
260
  });
276
261
 
277
- window.addEventListener('hashchange', () => {
278
- activateTab(window.location.hash);
279
- });
262
+ // Deep-link support: activate the tab matching the URL hash
263
+ const activateFromHash = () => {
264
+ let hash = window.location.hash.slice(1);
265
+ // A2A-41: backward-compat alias — old bookmarks/links using #settings
266
+ // still work after rename to #permissions
267
+ if (hash === 'settings') hash = 'permissions';
268
+ if (hash) {
269
+ // Use try/catch in case the tab group isn't fully ready
270
+ try { tabGroup.show(hash); } catch (err) {}
271
+ }
272
+ };
280
273
 
281
- // Deep-link into a tab with /dashboard/#logs, etc.
282
- activateTab(window.location.hash);
274
+ window.addEventListener('hashchange', activateFromHash);
275
+
276
+ // On initial load, activate from hash (wait for Shoelace to be ready)
277
+ if (tabGroup.updateComplete) {
278
+ tabGroup.updateComplete.then(activateFromHash);
279
+ } else {
280
+ // Fallback: try after a short delay
281
+ setTimeout(activateFromHash, 100);
282
+ }
283
283
  }
284
284
 
285
285
  function norm(value) {
@@ -307,6 +307,35 @@ function contactLabel(contact) {
307
307
  return String(contact?.name || '').trim() || String(contact?.host || '').trim() || '-';
308
308
  }
309
309
 
310
+ function getPinnedContacts() {
311
+ try {
312
+ const raw = localStorage.getItem('a2a-pinned-contacts');
313
+ if (!raw) return [];
314
+ const parsed = JSON.parse(raw);
315
+ return Array.isArray(parsed) ? parsed : [];
316
+ } catch (err) {
317
+ return [];
318
+ }
319
+ }
320
+
321
+ function togglePin(contactId) {
322
+ const id = String(contactId || '');
323
+ if (!id) return;
324
+ const pinned = getPinnedContacts();
325
+ const index = pinned.indexOf(id);
326
+ if (index >= 0) {
327
+ pinned.splice(index, 1);
328
+ } else {
329
+ pinned.push(id);
330
+ }
331
+ try {
332
+ localStorage.setItem('a2a-pinned-contacts', JSON.stringify(pinned));
333
+ } catch (err) {
334
+ // localStorage may be unavailable
335
+ }
336
+ renderContacts();
337
+ }
338
+
310
339
  function renderContacts() {
311
340
  const el = document.getElementById('contacts-sections');
312
341
  if (!el) return;
@@ -318,30 +347,18 @@ function renderContacts() {
318
347
  .filter(c => isMine(c))
319
348
  .sort((a, b) => contactLabel(a).localeCompare(contactLabel(b)));
320
349
 
350
+ const pinnedIds = getPinnedContacts();
321
351
  const lastCalled = contacts
322
- .filter(c => c && c.last_call_at)
323
- .sort((a, b) => String(b.last_call_at || '').localeCompare(String(a.last_call_at || '')))
352
+ .filter(c => c && c.last_call_at && !isMine(c))
353
+ .sort((a, b) => {
354
+ const aPinned = pinnedIds.includes(String(a.id));
355
+ const bPinned = pinnedIds.includes(String(b.id));
356
+ if (aPinned && !bPinned) return -1;
357
+ if (!aPinned && bPinned) return 1;
358
+ return String(b.last_call_at || '').localeCompare(String(a.last_call_at || ''));
359
+ })
324
360
  .slice(0, 12);
325
361
 
326
- const groups = new Map();
327
- for (const c of contacts) {
328
- const owner = String(c?.owner || '').trim() || '(unknown owner)';
329
- if (!groups.has(owner)) groups.set(owner, []);
330
- groups.get(owner).push(c);
331
- }
332
-
333
- const owners = Array.from(groups.keys()).sort((a, b) => {
334
- // Keep local owner group near the top (after the dedicated "My agents" section).
335
- const local = norm(getLocalOwnerName());
336
- const aIsLocal = local && norm(a) === local;
337
- const bIsLocal = local && norm(b) === local;
338
- if (aIsLocal && !bIsLocal) return -1;
339
- if (!aIsLocal && bIsLocal) return 1;
340
- if (a === '(unknown owner)' && b !== '(unknown owner)') return 1;
341
- if (a !== '(unknown owner)' && b === '(unknown owner)') return -1;
342
- return a.localeCompare(b);
343
- });
344
-
345
362
  const rowHtml = (c, opts = {}) => {
346
363
  const canCall = Boolean(c?.can_call);
347
364
  const mine = Boolean(c?.is_mine);
@@ -350,13 +367,17 @@ function renderContacts() {
350
367
  const lastCallAt = c?.last_call_at ? fmtDate(c.last_call_at) : '-';
351
368
  const calls = Number.isFinite(c?.call_count) ? c.call_count : (c?.call_count || 0);
352
369
  const isSelected = selected && String(c?.id) === selected;
370
+ const isPinned = pinnedIds.includes(String(c?.id));
353
371
 
354
372
  const actionBits = [];
373
+ if (opts.showPin) {
374
+ actionBits.push(`<sl-icon-button name="${isPinned ? 'pin-fill' : 'pin'}" class="pin-btn${isPinned ? ' pinned' : ''}" data-pin-contact="${esc(c.id)}" title="${isPinned ? 'Unpin' : 'Pin to top'}"></sl-icon-button>`);
375
+ }
355
376
  if (c?.last_call_id) {
356
- actionBits.push(`<button data-open-call="${esc(c.last_call_id)}" type="button">Transcript</button>`);
377
+ actionBits.push(`<sl-button size="small" data-open-call="${esc(c.last_call_id)}">Transcript</sl-button>`);
357
378
  }
358
- actionBits.push(`<button data-toggle-mine="${esc(c.id)}" type="button">${mine ? 'Unmark mine' : 'Mark mine'}</button>`);
359
- actionBits.push(`<button data-remove-contact="${esc(c.id)}" type="button">Remove</button>`);
379
+ actionBits.push(`<sl-button size="small" data-toggle-mine="${esc(c.id)}">${mine ? 'Unmark mine' : 'Mark mine'}</sl-button>`);
380
+ actionBits.push(`<sl-button size="small" variant="danger" data-remove-contact="${esc(c.id)}">Remove</sl-button>`);
360
381
 
361
382
  const locationCell = opts.showLocation ? `<td>${esc(formatLocation(c))}</td>` : '';
362
383
  const ownerCell = opts.showOwner ? `<td>${esc(c?.owner || '-')}</td>` : '';
@@ -366,8 +387,8 @@ function renderContacts() {
366
387
  <tr ${isSelected ? 'data-selected="1"' : ''}>
367
388
  <td>
368
389
  <div class="row" style="margin:0;">
369
- <button class="btn-link" data-contact-select="${esc(c.id)}" type="button">${esc(contactLabel(c))}</button>
370
- <button data-contact-call="${esc(c.id)}" type="button" ${canCall ? '' : 'disabled'}>Call</button>
390
+ <sl-button variant="text" size="small" data-contact-select="${esc(c.id)}">${esc(contactLabel(c))}</sl-button>
391
+ <sl-button size="small" variant="primary" data-contact-call="${esc(c.id)}" ${canCall ? '' : 'disabled'}>Call</sl-button>
371
392
  </div>
372
393
  </td>
373
394
  ${locationCell}
@@ -400,30 +421,48 @@ function renderContacts() {
400
421
  };
401
422
 
402
423
  const myAgentsSection = `
403
- <div class="card">
424
+ <sl-card>
404
425
  <h3>My agents</h3>
405
426
  ${tableHtml(myAgents, { showLocation: true, showOwner: false, showSummary: false })}
406
- </div>
427
+ </sl-card>
407
428
  `;
408
429
 
409
430
  const lastCalledSection = `
410
- <div class="card">
431
+ <sl-card>
411
432
  <h3>Last called agents</h3>
412
- ${tableHtml(lastCalled, { showLocation: false, showOwner: true, showSummary: false })}
413
- </div>
433
+ ${tableHtml(lastCalled, { showLocation: false, showOwner: true, showSummary: false, showPin: true })}
434
+ </sl-card>
414
435
  `;
415
436
 
416
- const groupedSections = owners.map(owner => {
417
- const rows = (groups.get(owner) || []).slice().sort((a, b) => contactLabel(a).localeCompare(contactLabel(b)));
437
+ const otherContacts = contacts.filter(c => !isMine(c));
438
+ const otherGroups = new Map();
439
+ for (const c of otherContacts) {
440
+ const owner = String(c?.owner || '').trim() || '(unknown owner)';
441
+ if (!otherGroups.has(owner)) otherGroups.set(owner, []);
442
+ otherGroups.get(owner).push(c);
443
+ }
444
+
445
+ const otherOwners = Array.from(otherGroups.keys()).sort((a, b) => {
446
+ if (a === '(unknown owner)' && b !== '(unknown owner)') return 1;
447
+ if (a !== '(unknown owner)' && b === '(unknown owner)') return -1;
448
+ return a.localeCompare(b);
449
+ });
450
+
451
+ const groupedSections = otherOwners.map(owner => {
452
+ const rows = (otherGroups.get(owner) || []).slice().sort((a, b) => contactLabel(a).localeCompare(contactLabel(b)));
418
453
  return `
419
- <div class="card">
454
+ <sl-card>
420
455
  <h3>${esc(owner)}</h3>
421
456
  ${tableHtml(rows, { showLocation: false, showOwner: false, showSummary: true })}
422
- </div>
457
+ </sl-card>
423
458
  `;
424
459
  }).join('');
425
460
 
426
- el.innerHTML = `${myAgentsSection}${lastCalledSection}${groupedSections}`;
461
+ const otherAgentsHeading = otherOwners.length
462
+ ? `<h3 style="margin-top:1rem;">Other Agents</h3>`
463
+ : '';
464
+
465
+ el.innerHTML = `${myAgentsSection}${lastCalledSection}${otherAgentsHeading}${groupedSections}`;
427
466
  }
428
467
 
429
468
  async function loadContacts() {
@@ -437,6 +476,15 @@ function bindContactsActions() {
437
476
  const form = document.getElementById('add-contact-form');
438
477
  if (!form) return;
439
478
 
479
+ // Cancel button collapses the sl-details
480
+ const cancelBtn = document.getElementById('add-contact-cancel');
481
+ const addDetails = document.getElementById('add-contact-details');
482
+ if (cancelBtn && addDetails) {
483
+ cancelBtn.addEventListener('click', () => {
484
+ addDetails.open = false;
485
+ });
486
+ }
487
+
440
488
  const urlEl = document.getElementById('add-contact-url');
441
489
  const mineEl = document.getElementById('add-contact-mine');
442
490
  const serverNameEl = document.getElementById('add-contact-server-name');
@@ -449,9 +497,9 @@ function bindContactsActions() {
449
497
  serverNameEl.value = match[1];
450
498
  }
451
499
  };
452
- urlEl?.addEventListener('blur', defaultServerNameFromUrl);
453
- urlEl?.addEventListener('change', defaultServerNameFromUrl);
454
- mineEl?.addEventListener('change', () => {
500
+ urlEl?.addEventListener('sl-blur', defaultServerNameFromUrl);
501
+ urlEl?.addEventListener('sl-change', defaultServerNameFromUrl);
502
+ mineEl?.addEventListener('sl-change', () => {
455
503
  if (!serverNameEl) return;
456
504
  serverNameEl.disabled = !mineEl.checked;
457
505
  if (mineEl.checked) {
@@ -467,7 +515,7 @@ function bindContactsActions() {
467
515
  const url = document.getElementById('add-contact-url').value.trim();
468
516
  const name = document.getElementById('add-contact-name').value.trim();
469
517
  const owner = document.getElementById('add-contact-owner').value.trim();
470
- const isMine = Boolean(document.getElementById('add-contact-mine')?.checked);
518
+ const isMineVal = Boolean(document.getElementById('add-contact-mine')?.checked);
471
519
  const serverName = document.getElementById('add-contact-server-name').value.trim();
472
520
  const tagsRaw = document.getElementById('add-contact-tags').value.trim();
473
521
  const notes = document.getElementById('add-contact-notes').value.trim();
@@ -497,7 +545,7 @@ function bindContactsActions() {
497
545
  invite_url: url,
498
546
  name: name || undefined,
499
547
  owner: owner || undefined,
500
- is_mine: isMine,
548
+ is_mine: isMineVal,
501
549
  server_name: serverName || undefined,
502
550
  tags,
503
551
  notes: notes || undefined,
@@ -506,15 +554,26 @@ function bindContactsActions() {
506
554
  });
507
555
  showNotice('Contact added');
508
556
  form.reset();
557
+ // Collapse the sl-details after successful add
558
+ if (addDetails) addDetails.open = false;
509
559
  await loadContacts();
510
560
  } catch (err) {
511
561
  showNotice(err.message);
512
562
  }
513
563
  });
514
564
 
515
- const panel = document.getElementById('tab-contacts');
565
+ // Event delegation on the contacts tab panel
566
+ const panel = document.querySelector('sl-tab-panel[name="contacts"]');
516
567
  panel?.addEventListener('click', async (e) => {
517
- const selectBtn = e.target.closest('button[data-contact-select]');
568
+ const pinBtn = e.target.closest('[data-pin-contact]');
569
+ if (pinBtn) {
570
+ e.preventDefault();
571
+ const id = pinBtn.dataset.pinContact;
572
+ if (id) togglePin(id);
573
+ return;
574
+ }
575
+
576
+ const selectBtn = e.target.closest('[data-contact-select]');
518
577
  if (selectBtn) {
519
578
  e.preventDefault();
520
579
  const id = selectBtn.dataset.contactSelect;
@@ -524,14 +583,14 @@ function bindContactsActions() {
524
583
  return;
525
584
  }
526
585
 
527
- const openBtn = e.target.closest('button[data-open-call]');
586
+ const openBtn = e.target.closest('[data-open-call]');
528
587
  if (openBtn) {
529
588
  e.preventDefault();
530
589
  openCallTranscript(openBtn.dataset.openCall);
531
590
  return;
532
591
  }
533
592
 
534
- const mineBtn = e.target.closest('button[data-toggle-mine]');
593
+ const mineBtn = e.target.closest('[data-toggle-mine]');
535
594
  if (mineBtn) {
536
595
  e.preventDefault();
537
596
  const id = mineBtn.dataset.toggleMine;
@@ -558,7 +617,7 @@ function bindContactsActions() {
558
617
  return;
559
618
  }
560
619
 
561
- const removeBtn = e.target.closest('button[data-remove-contact]');
620
+ const removeBtn = e.target.closest('[data-remove-contact]');
562
621
  if (removeBtn) {
563
622
  e.preventDefault();
564
623
  const id = removeBtn.dataset.removeContact;
@@ -580,7 +639,7 @@ function bindContactsActions() {
580
639
  return;
581
640
  }
582
641
 
583
- const callBtn = e.target.closest('button[data-contact-call]');
642
+ const callBtn = e.target.closest('[data-contact-call]');
584
643
  if (callBtn) {
585
644
  e.preventDefault();
586
645
  const id = callBtn.dataset.contactCall;
@@ -645,15 +704,35 @@ async function loadCallDetail(conversationId) {
645
704
  const payload = await request(`/calls/${encodeURIComponent(conversationId)}?messages=40`);
646
705
  const call = payload.call;
647
706
  const el = document.getElementById('call-detail');
648
- const messages = (call.recentMessages || [])
649
- .map(msg => `[${fmtDate(msg.timestamp)}] ${msg.direction}: ${msg.content}`)
707
+
708
+ // Summary: prefer agent-generated summary, fall back to owner summary
709
+ const summaryText = call.summary || call.ownerContext?.summary || '';
710
+ const summaryHtml = summaryText
711
+ ? `<pre class="summary">${esc(summaryText)}</pre>`
712
+ : `<p class="summary-pending"><em>${call.status === 'active' ? 'Call in progress\u2026' : 'Summary pending\u2026'}</em></p>`;
713
+
714
+ // Full transcript in a collapsible section
715
+ const messages = (call.recentMessages || []);
716
+ const transcriptLines = messages
717
+ .map(msg => `[${esc(fmtDate(msg.timestamp))}] ${esc(msg.direction)}: ${esc(msg.content)}`)
650
718
  .join('\n\n');
719
+ const totalMessages = call.messageCount || messages.length;
720
+ const countLabel = messages.length < totalMessages
721
+ ? `${messages.length} of ${totalMessages} messages`
722
+ : `${messages.length} message${messages.length === 1 ? '' : 's'}`;
723
+ const transcriptHtml = messages.length
724
+ ? `<sl-details class="transcript-details" summary="Full Transcript (${countLabel})">
725
+ <pre class="transcript">${transcriptLines}</pre>
726
+ </sl-details>`
727
+ : '';
728
+
651
729
  el.innerHTML = `
652
- <h3>Call Detail: ${call.id}</h3>
653
- <p><strong>Contact:</strong> ${call.contact?.name || call.contact || '-'}</p>
654
- <p><strong>Status:</strong> ${call.status || '-'}</p>
655
- <p><strong>Summary:</strong> ${(call.summary || call.ownerContext?.summary || '-')}</p>
656
- <pre class="summary">${messages || 'No messages recorded.'}</pre>
730
+ <h3>Call Detail: ${esc(call.id)}</h3>
731
+ <p><strong>Contact:</strong> ${esc(call.contact?.name || call.contact || '-')}</p>
732
+ <p><strong>Status:</strong> ${esc(call.status || '-')}</p>
733
+ <p><strong>Summary:</strong></p>
734
+ ${summaryHtml}
735
+ ${transcriptHtml}
657
736
  `;
658
737
  }
659
738
 
@@ -690,7 +769,7 @@ function renderContactDetail() {
690
769
  const resultHtml = result
691
770
  ? `<div style="margin-top:0.6rem;">
692
771
  <strong>Last call result:</strong> ${result.success ? 'success' : 'failed'}<br>
693
- ${result.conversation_id ? `Conversation: <span class="mono">${esc(result.conversation_id)}</span> <button data-open-call="${esc(result.conversation_id)}" type="button">Transcript</button><br>` : ''}
772
+ ${result.conversation_id ? `Conversation: <span class="mono">${esc(result.conversation_id)}</span> <sl-button size="small" data-open-call="${esc(result.conversation_id)}">Transcript</sl-button><br>` : ''}
694
773
  ${result.error ? `<span class="mono">${esc(result.error)}</span><br>` : ''}
695
774
  ${result.response ? `<pre class="summary">${esc(String(result.response))}</pre>` : ''}
696
775
  </div>`
@@ -705,7 +784,7 @@ function renderContactDetail() {
705
784
  <td>${esc(call.status || '-')}</td>
706
785
  <td>${esc(fmtDate(call.last_message_at))}</td>
707
786
  <td title="${esc(summary)}">${esc(preview)}</td>
708
- <td><button data-open-call="${esc(call.id)}" type="button">Transcript</button></td>
787
+ <td><sl-button size="small" data-open-call="${esc(call.id)}">Transcript</sl-button></td>
709
788
  </tr>
710
789
  `;
711
790
  }).join('');
@@ -713,16 +792,16 @@ function renderContactDetail() {
713
792
  el.innerHTML = `
714
793
  <div class="row">
715
794
  <h3 style="margin:0;">Contact: ${esc(contactLabel(contact))}</h3>
716
- <button data-contact-call="${esc(contact.id)}" type="button" ${canCall ? '' : 'disabled'}>Call</button>
717
- <button data-remove-contact="${esc(contact.id)}" type="button">Remove</button>
795
+ <sl-button size="small" variant="primary" data-contact-call="${esc(contact.id)}" ${canCall ? '' : 'disabled'}>Call</sl-button>
796
+ <sl-button size="small" variant="danger" data-remove-contact="${esc(contact.id)}">Remove</sl-button>
718
797
  </div>
719
798
 
720
- <div class="row" style="margin-bottom:0.4rem;">
721
- <div><strong>Mine:</strong> ${contact.is_mine ? 'yes' : 'no'}</div>
722
- <div><strong>Owner:</strong> ${esc(contact.owner || '-')}</div>
723
- <div><strong>Web address:</strong> <span class="mono">${esc(contact.web_address || contact.host || '-')}</span></div>
724
- <div><strong>Server name:</strong> ${esc(contact.server_name || '-')}</div>
725
- </div>
799
+ <div class="row" style="margin-bottom:0.4rem;">
800
+ <div><strong>Mine:</strong> ${contact.is_mine ? 'yes' : 'no'}</div>
801
+ <div><strong>Owner:</strong> ${esc(contact.owner || '-')}</div>
802
+ <div><strong>Web address:</strong> <span class="mono">${esc(contact.web_address || contact.host || '-')}</span></div>
803
+ <div><strong>Server name:</strong> ${esc(contact.server_name || '-')}</div>
804
+ </div>
726
805
  <div class="row">
727
806
  <div><strong>Status:</strong> ${esc(contact.status || '-')}</div>
728
807
  <div><strong>Total calls:</strong> ${esc(String(contact.call_count || 0))}</div>
@@ -731,48 +810,45 @@ function renderContactDetail() {
731
810
 
732
811
  ${resultHtml}
733
812
 
734
- <details style="margin-top:0.8rem;" open>
735
- <summary><strong>Edit contact</strong></summary>
736
- <form id="contact-edit-form" data-contact-id="${esc(contact.id)}" style="margin-top:0.6rem;">
737
- <label>Agent name <input id="contact-edit-name" type="text" value="${esc(contact.name || '')}"></label>
738
- <label>Owner name <input id="contact-edit-owner" type="text" value="${esc(contact.owner || '')}"></label>
739
- <label><input id="contact-edit-mine" type="checkbox" ${contact.is_mine ? 'checked' : ''}> Mark as mine (personal agent)</label>
740
- <label>Server name (my agents only) <input id="contact-edit-server-name" type="text" value="${esc(contact.server_name || '')}" ${contact.is_mine ? '' : 'disabled'}></label>
741
- <label>Tags <input id="contact-edit-tags" type="text" value="${esc(tagsText)}" placeholder="comma,separated"></label>
742
- <label>Notes <textarea id="contact-edit-notes" rows="3">${esc(contact.notes || '')}</textarea></label>
743
- <label>Fields (JSON) <textarea id="contact-edit-fields" rows="5">${esc(fieldsText)}</textarea></label>
744
- <div class="row">
745
- <button type="submit">Save</button>
746
- </div>
747
- </form>
748
- </details>
749
-
750
- <details style="margin-top:0.8rem;" open>
751
- <summary><strong>Call</strong></summary>
813
+ <sl-details summary="Edit contact" open style="margin-top:0.8rem;">
814
+ <form id="contact-edit-form" data-contact-id="${esc(contact.id)}" style="margin-top:0.6rem;">
815
+ <sl-input id="contact-edit-name" label="Agent name" value="${esc(contact.name || '')}"></sl-input>
816
+ <sl-input id="contact-edit-owner" label="Owner name" value="${esc(contact.owner || '')}"></sl-input>
817
+ <sl-checkbox id="contact-edit-mine" ${contact.is_mine ? 'checked' : ''}>Mark as mine (personal agent)</sl-checkbox>
818
+ <sl-input id="contact-edit-server-name" label="Server name (my agents only)" value="${esc(contact.server_name || '')}" ${contact.is_mine ? '' : 'disabled'}></sl-input>
819
+ <sl-input id="contact-edit-tags" label="Tags" value="${esc(tagsText)}" placeholder="comma,separated"></sl-input>
820
+ <sl-textarea id="contact-edit-notes" label="Notes" rows="3" value="${esc(contact.notes || '')}"></sl-textarea>
821
+ <sl-textarea id="contact-edit-fields" label="Fields (JSON)" rows="5" value="${esc(fieldsText)}"></sl-textarea>
822
+ <div class="row">
823
+ <sl-button type="submit" variant="primary" size="small">Save</sl-button>
824
+ </div>
825
+ </form>
826
+ </sl-details>
827
+
828
+ <sl-details summary="Call" open style="margin-top:0.8rem;">
752
829
  <form id="contact-call-form" data-contact-id="${esc(contact.id)}" style="margin-top:0.6rem;">
753
- <label>Message <textarea id="contact-call-message" rows="4" placeholder="Message to send"></textarea></label>
830
+ <sl-textarea id="contact-call-message" label="Message" rows="4" placeholder="Message to send"></sl-textarea>
754
831
  <div class="row">
755
- <button type="submit" ${canCall ? '' : 'disabled'}>Call</button>
832
+ <sl-button type="submit" variant="primary" size="small" ${canCall ? '' : 'disabled'}>Call</sl-button>
756
833
  </div>
757
834
  </form>
758
- </details>
835
+ </sl-details>
759
836
 
760
- <details style="margin-top:0.8rem;">
761
- <summary><strong>Call history</strong></summary>
837
+ <sl-details summary="Call history" style="margin-top:0.8rem;">
762
838
  <div style="margin-top:0.6rem;">
763
839
  <table>
764
840
  <thead><tr><th>ID</th><th>Status</th><th>Updated</th><th>Summary</th><th>Action</th></tr></thead>
765
841
  <tbody>${callRows || '<tr><td colspan="5">No calls found.</td></tr>'}</tbody>
766
842
  </table>
767
843
  </div>
768
- </details>
844
+ </sl-details>
769
845
  `;
770
846
 
771
847
  const editForm = document.getElementById('contact-edit-form');
772
848
  if (editForm) {
773
849
  const mineEl = document.getElementById('contact-edit-mine');
774
850
  const serverNameEl = document.getElementById('contact-edit-server-name');
775
- mineEl?.addEventListener('change', () => {
851
+ mineEl?.addEventListener('sl-change', () => {
776
852
  if (!serverNameEl) return;
777
853
  serverNameEl.disabled = !mineEl.checked;
778
854
  });
@@ -802,19 +878,19 @@ function renderContactDetail() {
802
878
  }
803
879
  }
804
880
 
805
- try {
806
- await request(`/contacts/${encodeURIComponent(id)}`, {
807
- method: 'PUT',
808
- body: JSON.stringify({
809
- name: document.getElementById('contact-edit-name').value,
810
- owner: document.getElementById('contact-edit-owner').value,
811
- is_mine: Boolean(document.getElementById('contact-edit-mine')?.checked),
812
- server_name: document.getElementById('contact-edit-server-name').value,
813
- notes: document.getElementById('contact-edit-notes').value,
814
- tags,
815
- fields
816
- })
817
- });
881
+ try {
882
+ await request(`/contacts/${encodeURIComponent(id)}`, {
883
+ method: 'PUT',
884
+ body: JSON.stringify({
885
+ name: document.getElementById('contact-edit-name').value,
886
+ owner: document.getElementById('contact-edit-owner').value,
887
+ is_mine: Boolean(document.getElementById('contact-edit-mine')?.checked),
888
+ server_name: document.getElementById('contact-edit-server-name').value,
889
+ notes: document.getElementById('contact-edit-notes').value,
890
+ tags,
891
+ fields
892
+ })
893
+ });
818
894
  showNotice('Contact saved');
819
895
  await loadContacts();
820
896
  await loadCallsForContact(id);
@@ -933,10 +1009,10 @@ function renderLogStats() {
933
1009
  <strong>Total:</strong> ${stats.total || 0}
934
1010
  </div>
935
1011
  <div class="row">
936
- <strong>By level:</strong> ${levels.map(([k, v]) => `${esc(k)}=${v}`).join(' · ') || '(none)'}
1012
+ <strong>By level:</strong> ${levels.map(([k, v]) => `${esc(k)}=${v}`).join(' \u00b7 ') || '(none)'}
937
1013
  </div>
938
1014
  <div class="row">
939
- <strong>Top components:</strong> ${components.map(([k, v]) => `${esc(k)}=${v}`).join(' · ') || '(none)'}
1015
+ <strong>Top components:</strong> ${components.map(([k, v]) => `${esc(k)}=${v}`).join(' \u00b7 ') || '(none)'}
940
1016
  </div>
941
1017
  `;
942
1018
  }
@@ -964,7 +1040,7 @@ function renderTraceDetail() {
964
1040
  el.innerHTML = `
965
1041
  <div class="row">
966
1042
  <h3 style="margin:0;">Trace: <span class="mono">${esc(state.trace.trace_id || '')}</span></h3>
967
- <button id="clear-trace">Clear</button>
1043
+ <sl-button id="clear-trace" size="small">Clear</sl-button>
968
1044
  </div>
969
1045
  <pre class="summary mono">${esc(lines || 'No trace logs.')}</pre>
970
1046
  `;
@@ -990,8 +1066,8 @@ function renderLogs() {
990
1066
  <td>${esc(row.component || '-')}</td>
991
1067
  <td>${esc(row.event || '-')}</td>
992
1068
  <td title="${esc(row.message || '')}">${esc(String(row.message || '').slice(0, 120) || '-')}</td>
993
- <td class="mono">${esc(trace ? trace.slice(0, 14) + '' : '-')}</td>
994
- <td class="mono">${esc(row.conversation_id ? row.conversation_id.slice(0, 14) + '' : '-')}</td>
1069
+ <td class="mono">${esc(trace ? trace.slice(0, 14) + '\u2026' : '-')}</td>
1070
+ <td class="mono">${esc(row.conversation_id ? row.conversation_id.slice(0, 14) + '\u2026' : '-')}</td>
995
1071
  <td class="mono">${esc(row.token_id || '-')}</td>
996
1072
  <td>${esc(row.error_code || '-')}</td>
997
1073
  <td>${esc(row.status_code ?? '-')}</td>
@@ -1022,6 +1098,198 @@ async function loadTrace(traceId) {
1022
1098
  renderTraceDetail();
1023
1099
  }
1024
1100
 
1101
+ // A2A-41: emoji map for visual tier differentiation. Standard tiers get
1102
+ // recognizable icons; custom/user-created tiers get a wrench.
1103
+ const TIER_EMOJIS = { public: '\u{1F310}', friends: '\u{1F46B}', family: '\u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466}' };
1104
+
1105
+ // A2A-41: tool descriptions for the checkbox UI. These match the tools
1106
+ // available in Claude Code that an agent owner might want to expose to callers.
1107
+ const TOOL_DESCRIPTIONS = {
1108
+ 'Bash': 'Execute shell commands \u2014 full access, can run anything',
1109
+ 'Bash(readonly)': 'Execute read-only shell commands \u2014 no writes, no installs',
1110
+ 'Read': 'Read files from the workspace',
1111
+ 'Grep': 'Search file contents with regex patterns',
1112
+ 'Glob': 'Find files by name patterns',
1113
+ 'WebSearch': 'Search the web for information',
1114
+ 'WebFetch': 'Fetch and read web page content'
1115
+ };
1116
+
1117
+ // A2A-41: standard tier order for inheritance. Custom tiers are not in this list.
1118
+ const TIER_ORDER = ['public', 'friends', 'family'];
1119
+
1120
+ // A2A-41: renders tool checkboxes instead of a textarea. Each tool gets
1121
+ // a checkbox with its description. Checked state comes from tier.allowed_tools.
1122
+ function renderToolCheckboxes(allowedTools) {
1123
+ const container = document.getElementById('tier-tools-list');
1124
+ container.innerHTML = Object.entries(TOOL_DESCRIPTIONS).map(([tool, desc]) => {
1125
+ const checked = (allowedTools || []).includes(tool) ? 'checked' : '';
1126
+ return `<sl-checkbox value="${esc(tool)}" ${checked}><strong>${esc(tool)}</strong> \u2014 <span class="tool-desc">${esc(desc)}</span></sl-checkbox>`;
1127
+ }).join('');
1128
+ }
1129
+
1130
+ // A2A-41: renders topics as expandable card rows with descriptions.
1131
+ // Data comes from tier.manifest.topics (array of {topic, description} objects).
1132
+ // Falls back to tier.topics (flat string array) for topics without manifest data.
1133
+ function renderTopicList(tier) {
1134
+ const container = document.getElementById('tier-topics-list');
1135
+ const manifestTopics = tier.manifest?.topics || [];
1136
+ const flatTopics = tier.topics || [];
1137
+
1138
+ // A2A-41: prefer manifest data (has descriptions), fall back to flat array
1139
+ const allTopics = manifestTopics.length > 0
1140
+ ? manifestTopics.map(t => ({ label: t.topic, desc: t.description || '' }))
1141
+ : flatTopics.map(t => ({ label: t, desc: '' }));
1142
+
1143
+ const rowsHtml = allTopics.map(t => `
1144
+ <div class="topic-row" data-topic="${esc(t.label)}" data-type="topic">
1145
+ <span class="drag-handle">\u2807</span>
1146
+ <div class="topic-content">
1147
+ <div class="topic-header">
1148
+ <strong class="topic-label">${esc(t.label)}</strong>
1149
+ <sl-icon-button name="chevron-down" class="topic-expand-btn" label="Expand"></sl-icon-button>
1150
+ <sl-icon-button name="trash" class="topic-delete-btn" label="Delete"></sl-icon-button>
1151
+ </div>
1152
+ <div class="topic-description" style="display:none;">
1153
+ <p class="topic-desc-text">${esc(t.desc) || '<em>No description</em>'}</p>
1154
+ <sl-input class="topic-desc-edit" size="small" placeholder="Add description..." value="${esc(t.desc)}"></sl-input>
1155
+ </div>
1156
+ </div>
1157
+ </div>
1158
+ `).join('');
1159
+
1160
+ container.innerHTML = rowsHtml + `<button class="add-item-btn" data-type="topic">+ Add topic</button>`;
1161
+ }
1162
+
1163
+ // A2A-41: renders goals as expandable card rows, identical pattern to topics.
1164
+ // Data from tier.manifest.objectives (array of {objective, description}).
1165
+ function renderGoalList(tier) {
1166
+ const container = document.getElementById('tier-goals-list');
1167
+ const manifestGoals = tier.manifest?.objectives || [];
1168
+ const flatGoals = tier.goals || [];
1169
+
1170
+ const allGoals = manifestGoals.length > 0
1171
+ ? manifestGoals.map(g => ({ label: g.objective || g.topic, desc: g.description || '' }))
1172
+ : flatGoals.map(g => ({ label: g, desc: '' }));
1173
+
1174
+ const rowsHtml = allGoals.map(g => `
1175
+ <div class="topic-row" data-topic="${esc(g.label)}" data-type="goal">
1176
+ <span class="drag-handle">\u2807</span>
1177
+ <div class="topic-content">
1178
+ <div class="topic-header">
1179
+ <strong class="topic-label">${esc(g.label)}</strong>
1180
+ <sl-icon-button name="chevron-down" class="topic-expand-btn" label="Expand"></sl-icon-button>
1181
+ <sl-icon-button name="trash" class="topic-delete-btn" label="Delete"></sl-icon-button>
1182
+ </div>
1183
+ <div class="topic-description" style="display:none;">
1184
+ <p class="topic-desc-text">${esc(g.desc) || '<em>No description</em>'}</p>
1185
+ <sl-input class="topic-desc-edit" size="small" placeholder="Add description..." value="${esc(g.desc)}"></sl-input>
1186
+ </div>
1187
+ </div>
1188
+ </div>
1189
+ `).join('');
1190
+
1191
+ container.innerHTML = rowsHtml + `<button class="add-item-btn" data-type="goal">+ Add goal</button>`;
1192
+ }
1193
+
1194
+ // A2A-41: event delegation for topic and goal list interactions.
1195
+ // Uses a single click handler on each container instead of per-row binding,
1196
+ // preventing listener accumulation when topics are added dynamically.
1197
+ function bindItemListDelegation() {
1198
+ ['tier-topics-list', 'tier-goals-list'].forEach(containerId => {
1199
+ const container = document.getElementById(containerId);
1200
+ if (!container) return;
1201
+
1202
+ container.addEventListener('click', (e) => {
1203
+ // Expand/collapse
1204
+ const expandBtn = e.target.closest('.topic-expand-btn');
1205
+ if (expandBtn) {
1206
+ const row = expandBtn.closest('.topic-row');
1207
+ const desc = row.querySelector('.topic-description');
1208
+ if (desc) {
1209
+ const isHidden = desc.style.display === 'none';
1210
+ desc.style.display = isHidden ? '' : 'none';
1211
+ expandBtn.name = isHidden ? 'chevron-up' : 'chevron-down';
1212
+ }
1213
+ return;
1214
+ }
1215
+
1216
+ // Delete
1217
+ const deleteBtn = e.target.closest('.topic-delete-btn');
1218
+ if (deleteBtn) {
1219
+ deleteBtn.closest('.topic-row').remove();
1220
+ return;
1221
+ }
1222
+
1223
+ // Add new item
1224
+ const addBtn = e.target.closest('.add-item-btn');
1225
+ if (addBtn) {
1226
+ const type = addBtn.dataset.type;
1227
+ const label = type === 'topic' ? 'Topic name' : 'Goal name';
1228
+ const newRow = document.createElement('div');
1229
+ newRow.className = 'topic-row';
1230
+ newRow.dataset.type = type;
1231
+ newRow.innerHTML = `
1232
+ <span class="drag-handle">\u2807</span>
1233
+ <div class="topic-content">
1234
+ <sl-input class="new-item-label" size="small" placeholder="${label}" autofocus></sl-input>
1235
+ <sl-input class="new-item-desc" size="small" placeholder="Description (optional)"></sl-input>
1236
+ <div class="row" style="margin-top:0.3rem;">
1237
+ <sl-button size="small" variant="primary" class="confirm-add-btn">Add</sl-button>
1238
+ <sl-button size="small" class="cancel-add-btn">Cancel</sl-button>
1239
+ </div>
1240
+ </div>
1241
+ `;
1242
+ container.insertBefore(newRow, addBtn);
1243
+ return;
1244
+ }
1245
+
1246
+ // Confirm add
1247
+ const confirmBtn = e.target.closest('.confirm-add-btn');
1248
+ if (confirmBtn) {
1249
+ const row = confirmBtn.closest('.topic-row');
1250
+ const nameInput = row.querySelector('.new-item-label');
1251
+ const descInput = row.querySelector('.new-item-desc');
1252
+ const name = nameInput.value.trim();
1253
+ if (!name) { nameInput.focus(); return; }
1254
+
1255
+ row.dataset.topic = name;
1256
+ row.innerHTML = `
1257
+ <span class="drag-handle">\u2807</span>
1258
+ <div class="topic-content">
1259
+ <div class="topic-header">
1260
+ <strong class="topic-label">${esc(name)}</strong>
1261
+ <sl-icon-button name="chevron-down" class="topic-expand-btn" label="Expand"></sl-icon-button>
1262
+ <sl-icon-button name="trash" class="topic-delete-btn" label="Delete"></sl-icon-button>
1263
+ </div>
1264
+ <div class="topic-description" style="display:none;">
1265
+ <p class="topic-desc-text">${esc(descInput.value)}</p>
1266
+ <sl-input class="topic-desc-edit" size="small" placeholder="Add description..." value="${esc(descInput.value)}"></sl-input>
1267
+ </div>
1268
+ </div>
1269
+ `;
1270
+ return;
1271
+ }
1272
+
1273
+ // Cancel add
1274
+ const cancelBtn = e.target.closest('.cancel-add-btn');
1275
+ if (cancelBtn) {
1276
+ cancelBtn.closest('.topic-row').remove();
1277
+ return;
1278
+ }
1279
+ });
1280
+
1281
+ // Description edit via sl-change (Shoelace event, delegated)
1282
+ container.addEventListener('sl-change', (e) => {
1283
+ const input = e.target.closest('.topic-desc-edit');
1284
+ if (input) {
1285
+ const row = input.closest('.topic-row');
1286
+ const textEl = row.querySelector('.topic-desc-text');
1287
+ if (textEl) textEl.textContent = input.value || '';
1288
+ }
1289
+ });
1290
+ });
1291
+ }
1292
+
1025
1293
  function fillTierSelects() {
1026
1294
  const tiers = (state.settings?.tiers || []).slice().sort((a, b) => a.id.localeCompare(b.id));
1027
1295
  const tierSelect = document.getElementById('tier-select');
@@ -1029,22 +1297,23 @@ function fillTierSelects() {
1029
1297
  const newTierCopy = document.getElementById('new-tier-copy-from');
1030
1298
  const inviteTier = document.getElementById('invite-tier');
1031
1299
 
1032
- [tierSelect, copyFrom, inviteTier].forEach(el => { el.innerHTML = ''; });
1033
- newTierCopy.innerHTML = '<option value="">None</option>';
1034
-
1035
- tiers.forEach(tier => {
1036
- const option = new Option(`${tier.id} (${tier.name || tier.id})`, tier.id);
1037
- tierSelect.add(option.cloneNode(true));
1038
- copyFrom.add(option.cloneNode(true));
1039
- inviteTier.add(option.cloneNode(true));
1040
- newTierCopy.add(option.cloneNode(true));
1041
- });
1300
+ const optionsHtml = tiers.map(tier => {
1301
+ const emoji = TIER_EMOJIS[tier.id] || '\u{1F527}';
1302
+ return `<sl-option value="${esc(tier.id)}">${emoji} ${esc(tier.name || tier.id)}</sl-option>`;
1303
+ }).join('');
1042
1304
 
1043
- if (tiers.length > 0) {
1044
- tierSelect.value = tiers[0].id;
1045
- copyFrom.value = tiers[0].id;
1046
- inviteTier.value = tiers[0].id;
1047
- renderTierEditor(tiers[0].id);
1305
+ tierSelect.innerHTML = optionsHtml;
1306
+ copyFrom.innerHTML = optionsHtml;
1307
+ inviteTier.innerHTML = optionsHtml;
1308
+ newTierCopy.innerHTML = `<sl-option value="">None</sl-option>${optionsHtml}`;
1309
+
1310
+ // A2A-41: default to 'public' — it's the base tier and most commonly edited
1311
+ const defaultTier = tiers.find(t => t.id === 'public') ? 'public' : tiers[0]?.id;
1312
+ if (defaultTier) {
1313
+ tierSelect.value = defaultTier;
1314
+ copyFrom.value = defaultTier;
1315
+ inviteTier.value = defaultTier;
1316
+ renderTierEditor(defaultTier);
1048
1317
  }
1049
1318
  }
1050
1319
 
@@ -1052,30 +1321,319 @@ function renderTierEditor(tierId) {
1052
1321
  const tier = (state.settings?.tiers || []).find(t => t.id === tierId);
1053
1322
  if (!tier) return;
1054
1323
 
1055
- document.getElementById('tier-id').value = tier.id;
1056
1324
  document.getElementById('tier-name').value = tier.name || tier.id;
1057
1325
  document.getElementById('tier-description').value = tier.description || '';
1058
- document.getElementById('tier-disclosure').value = tier.disclosure || 'minimal';
1059
- document.getElementById('tier-tools').value = toLines(tier.allowed_tools || []);
1060
- document.getElementById('tier-topics').value = toLines(tier.topics || []);
1061
- document.getElementById('tier-goals').value = toLines(tier.goals || []);
1326
+ renderToolCheckboxes(tier.allowed_tools);
1327
+ renderTopicList(tier);
1328
+ renderGoalList(tier);
1329
+ renderTierWarnings(tier);
1330
+ renderTierColumns();
1331
+ }
1332
+
1333
+ // A2A-41: renders the three-column drag zone showing all standard tiers
1334
+ // side-by-side. Inherited topics shown as grayed-out non-draggable rows.
1335
+ // Custom tiers are not shown here — they don't have a defined inheritance
1336
+ // hierarchy. HTML5 drag-and-drop does NOT work on touch devices (mobile).
1337
+ function renderTierColumns() {
1338
+ const container = document.getElementById('tier-columns');
1339
+ if (!container) return;
1340
+ const tiers = state.settings?.tiers || [];
1341
+ const toggle = document.getElementById('show-drag-columns');
1342
+ container.style.display = toggle?.checked ? '' : 'none';
1343
+
1344
+ const html = TIER_ORDER.map(tierId => {
1345
+ const tier = tiers.find(t => t.id === tierId);
1346
+ if (!tier) return '';
1347
+
1348
+ const emoji = TIER_EMOJIS[tierId] || '\u{1F527}';
1349
+ const tierIdx = TIER_ORDER.indexOf(tierId);
1350
+
1351
+ // Inherited topics from lower tiers
1352
+ let inheritedRows = '';
1353
+ for (let i = 0; i < tierIdx; i++) {
1354
+ const lowerTier = tiers.find(t => t.id === TIER_ORDER[i]);
1355
+ if (!lowerTier) continue;
1356
+ const lowerTopics = lowerTier.manifest?.topics?.length
1357
+ ? lowerTier.manifest.topics
1358
+ : (lowerTier.topics || []).map(t => ({ topic: t, description: '' }));
1359
+ lowerTopics.forEach(t => {
1360
+ inheritedRows += `
1361
+ <div class="topic-row inherited" data-topic="${esc(t.topic)}" data-tier="${esc(TIER_ORDER[i])}">
1362
+ <div class="topic-content">
1363
+ <div class="topic-header">
1364
+ <strong class="topic-label">${esc(t.topic)}</strong>
1365
+ <span class="inherited-badge">from ${esc(TIER_ORDER[i])}</span>
1366
+ </div>
1367
+ </div>
1368
+ </div>`;
1369
+ });
1370
+ }
1371
+
1372
+ // Own topics — draggable
1373
+ const ownTopics = tier.manifest?.topics?.length
1374
+ ? tier.manifest.topics
1375
+ : (tier.topics || []).map(t => ({ topic: t, description: '' }));
1376
+ const ownRows = ownTopics.map(t => `
1377
+ <div class="topic-row" draggable="true" data-topic="${esc(t.topic)}" data-tier="${esc(tierId)}">
1378
+ <span class="drag-handle">\u2807</span>
1379
+ <div class="topic-content">
1380
+ <div class="topic-header">
1381
+ <strong class="topic-label">${esc(t.topic)}</strong>
1382
+ </div>
1383
+ </div>
1384
+ </div>
1385
+ `).join('');
1386
+
1387
+ return `
1388
+ <div class="tier-column" data-tier="${esc(tierId)}">
1389
+ <h4>${emoji} ${esc(tier.name || tierId)}</h4>
1390
+ <div class="tier-drop-zone" data-tier="${esc(tierId)}">
1391
+ ${inheritedRows}${ownRows}
1392
+ </div>
1393
+ </div>`;
1394
+ }).join('');
1395
+
1396
+ container.innerHTML = html;
1397
+ bindDragEvents();
1398
+ }
1399
+
1400
+ // A2A-41: HTML5 drag-and-drop handlers for moving topics between tier columns.
1401
+ // On drop, both tiers are saved via Promise.all() to prevent data loss if one
1402
+ // request fails. On error, state is reloaded from server to reset UI.
1403
+ function bindDragEvents() {
1404
+ const zones = document.querySelectorAll('.tier-drop-zone');
1405
+
1406
+ document.querySelectorAll('.tier-columns .topic-row[draggable="true"]').forEach(row => {
1407
+ row.addEventListener('dragstart', (e) => {
1408
+ e.dataTransfer.setData('application/json', JSON.stringify({
1409
+ topic: row.dataset.topic,
1410
+ sourceTier: row.dataset.tier
1411
+ }));
1412
+ row.classList.add('dragging');
1413
+ });
1414
+ row.addEventListener('dragend', () => row.classList.remove('dragging'));
1415
+ });
1416
+
1417
+ zones.forEach(zone => {
1418
+ zone.addEventListener('dragover', (e) => {
1419
+ e.preventDefault();
1420
+ zone.classList.add('drag-over');
1421
+ });
1422
+ zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
1423
+ zone.addEventListener('drop', async (e) => {
1424
+ e.preventDefault();
1425
+ zone.classList.remove('drag-over');
1426
+
1427
+ let data;
1428
+ try { data = JSON.parse(e.dataTransfer.getData('application/json')); } catch { return; }
1429
+ const { topic, sourceTier } = data;
1430
+ const targetTier = zone.dataset.tier;
1431
+
1432
+ if (!topic || !sourceTier || !targetTier || sourceTier === targetTier) return;
1433
+
1434
+ const sourceTierData = (state.settings?.tiers || []).find(t => t.id === sourceTier);
1435
+ const targetTierData = (state.settings?.tiers || []).find(t => t.id === targetTier);
1436
+ if (!sourceTierData || !targetTierData) return;
1437
+
1438
+ const sourceTopics = (sourceTierData.topics || []).filter(t => t !== topic);
1439
+ const sourceManifestTopics = (sourceTierData.manifest?.topics || []).filter(t => t.topic !== topic);
1440
+ const movedManifest = (sourceTierData.manifest?.topics || []).find(t => t.topic === topic);
1441
+ const targetTopics = [...(targetTierData.topics || []), topic];
1442
+ const targetManifestTopics = [...(targetTierData.manifest?.topics || []), movedManifest || { topic, description: '' }];
1443
+
1444
+ // A2A-41: save both tiers atomically with Promise.all to prevent
1445
+ // data loss if one request fails. On error, reload from server.
1446
+ try {
1447
+ await Promise.all([
1448
+ request(`/settings/tiers/${encodeURIComponent(sourceTier)}`, {
1449
+ method: 'PUT',
1450
+ body: JSON.stringify({
1451
+ topics: sourceTopics,
1452
+ manifest: { topics: sourceManifestTopics, objectives: sourceTierData.manifest?.objectives || [] }
1453
+ })
1454
+ }),
1455
+ request(`/settings/tiers/${encodeURIComponent(targetTier)}`, {
1456
+ method: 'PUT',
1457
+ body: JSON.stringify({
1458
+ topics: targetTopics,
1459
+ manifest: { topics: targetManifestTopics, objectives: targetTierData.manifest?.objectives || [] }
1460
+ })
1461
+ })
1462
+ ]);
1463
+ showNotice(`Moved "${topic}" from ${sourceTier} to ${targetTier}`);
1464
+ } catch (err) {
1465
+ showNotice(`Move failed: ${err.message}. Reloading...`);
1466
+ }
1467
+ await loadSettings();
1468
+ });
1469
+ });
1470
+ }
1471
+
1472
+ // A2A-41: contextual validation warnings for the currently selected tier.
1473
+ // Warns about empty tiers, dangerous tool grants, and inverted tier sizes.
1474
+ function renderTierWarnings(tier) {
1475
+ const container = document.getElementById('tier-warnings');
1476
+ if (!container) return;
1477
+ const warnings = [];
1478
+
1479
+ // A2A-41: use manifest OR flat topics (not both) to avoid double-counting.
1480
+ // Manifest is preferred when non-empty; flat array is the fallback.
1481
+ // NOTE: can't use || for this because empty arrays are truthy in JS.
1482
+ const mTopics = tier.manifest?.topics;
1483
+ const topicCount = (mTopics && mTopics.length > 0 ? mTopics : (tier.topics || [])).length;
1484
+ if (topicCount === 0) {
1485
+ warnings.push({ level: 'warn', text: "This tier has no topics \u2014 callers won't have conversation context." });
1486
+ }
1487
+
1488
+ if (tier.id === 'public' && (tier.allowed_tools || []).includes('Bash')) {
1489
+ warnings.push({ level: 'danger', text: 'Bash (full access) is granted to the public tier \u2014 any caller can execute commands.' });
1490
+ }
1491
+
1492
+ if (tier.id === 'family') {
1493
+ const allTiers = state.settings?.tiers || [];
1494
+ const friends = allTiers.find(t => t.id === 'friends');
1495
+ if (friends) {
1496
+ const mFam = tier.manifest?.topics;
1497
+ const familyOwn = (mFam && mFam.length > 0 ? mFam : (tier.topics || [])).length;
1498
+ const mFri = friends.manifest?.topics;
1499
+ const friendsOwn = (mFri && mFri.length > 0 ? mFri : (friends.topics || [])).length;
1500
+ if (familyOwn < friendsOwn) {
1501
+ warnings.push({ level: 'info', text: 'Family tier has fewer topics than Friends \u2014 usually Family is the most open tier.' });
1502
+ }
1503
+ }
1504
+ }
1505
+
1506
+ container.innerHTML = warnings.map(w =>
1507
+ `<div class="tier-warning ${w.level}">${esc(w.text)}</div>`
1508
+ ).join('');
1509
+ }
1510
+
1511
+ // A2A-41: merges topics/goals/tools from the selected tier and all lower tiers,
1512
+ // mirroring the backend's getTopicsForTier() inheritance. Used by the preview dialog.
1513
+ function getPreviewData(tierId) {
1514
+ const selectedIndex = TIER_ORDER.indexOf(tierId);
1515
+ const tiers = state.settings?.tiers || [];
1516
+ const merged = { topics: [], objectives: [], tools: new Set(), do_not_discuss: [], never_disclose: [] };
1517
+
1518
+ // A2A-41: for custom tiers not in TIER_ORDER, show only own data.
1519
+ // No inheritance is applied because custom tiers have no defined hierarchy.
1520
+ if (selectedIndex < 0) {
1521
+ const t = tiers.find(t => t.id === tierId);
1522
+ if (t) {
1523
+ (t.manifest?.topics || []).forEach(item => merged.topics.push({ ...item, source: tierId }));
1524
+ (t.manifest?.objectives || []).forEach(item => merged.objectives.push({ ...item, source: tierId }));
1525
+ (t.allowed_tools || []).forEach(tool => merged.tools.add(tool));
1526
+ }
1527
+ merged.never_disclose = state.settings?.manifest?.never_disclose || [];
1528
+ return merged;
1529
+ }
1530
+
1531
+ for (let i = 0; i <= selectedIndex; i++) {
1532
+ const t = tiers.find(t => t.id === TIER_ORDER[i]);
1533
+ if (!t) continue;
1534
+ (t.manifest?.topics || []).forEach(item => merged.topics.push({ ...item, source: TIER_ORDER[i] }));
1535
+ (t.manifest?.objectives || []).forEach(item => merged.objectives.push({ ...item, source: TIER_ORDER[i] }));
1536
+ (t.manifest?.do_not_discuss || []).forEach(item => {
1537
+ if (!merged.do_not_discuss.includes(item)) merged.do_not_discuss.push(item);
1538
+ });
1539
+ (t.allowed_tools || []).forEach(tool => merged.tools.add(tool));
1540
+ }
1541
+
1542
+ merged.never_disclose = state.settings?.manifest?.never_disclose || [];
1543
+ return merged;
1544
+ }
1545
+
1546
+ // A2A-41: opens the caller preview dialog showing the merged effective view
1547
+ // for the selected tier. Helps the agent owner understand what a caller sees.
1548
+ function openCallerPreview() {
1549
+ const tierId = document.getElementById('tier-select').value;
1550
+ const data = getPreviewData(tierId);
1551
+ const emoji = TIER_EMOJIS[tierId] || '\u{1F527}';
1552
+ const tierName = (state.settings?.tiers || []).find(t => t.id === tierId)?.name || tierId;
1553
+
1554
+ const dialog = document.getElementById('preview-dialog');
1555
+ dialog.label = `\u{1F441} Caller Preview \u2014 ${emoji} ${tierName}`;
1556
+
1557
+ const topicsList = data.topics.length > 0
1558
+ ? data.topics.map(t => `<li><strong>${esc(t.topic)}</strong>${t.description ? ` \u2014 ${esc(t.description)}` : ''}</li>`).join('')
1559
+ : '<li><em>None configured</em></li>';
1560
+
1561
+ const goalsList = data.objectives.length > 0
1562
+ ? data.objectives.map(g => `<li><strong>${esc(g.objective || g.topic)}</strong>${g.description ? ` \u2014 ${esc(g.description)}` : ''}</li>`).join('')
1563
+ : '<li><em>None configured</em></li>';
1564
+
1565
+ const toolsList = data.tools.size > 0
1566
+ ? Array.from(data.tools).map(t => `<li><strong>${esc(t)}</strong>${TOOL_DESCRIPTIONS[t] ? ` \u2014 ${esc(TOOL_DESCRIPTIONS[t])}` : ''}</li>`).join('')
1567
+ : '<li><em>None configured</em></li>';
1568
+
1569
+ const dndList = data.do_not_discuss.length > 0
1570
+ ? data.do_not_discuss.map(d => `<li>${esc(typeof d === 'string' ? d : d.topic || '')}</li>`).join('')
1571
+ : '<li><em>None configured</em></li>';
1572
+
1573
+ const neverList = data.never_disclose.length > 0
1574
+ ? data.never_disclose.map(n => `<li>${esc(n)}</li>`).join('')
1575
+ : '<li><em>None configured</em></li>';
1576
+
1577
+ document.getElementById('preview-content').innerHTML = `
1578
+ <h4>Topics this caller can discuss:</h4>
1579
+ <ul>${topicsList}</ul>
1580
+ <h4>Goals:</h4>
1581
+ <ul>${goalsList}</ul>
1582
+ <h4>Tools available:</h4>
1583
+ <ul>${toolsList}</ul>
1584
+ <h4>Will not discuss:</h4>
1585
+ <ul>${dndList}</ul>
1586
+ <h4>Never disclosed (any tier):</h4>
1587
+ <ul>${neverList}</ul>
1588
+ `;
1589
+
1590
+ dialog.show();
1062
1591
  }
1063
1592
 
1064
1593
  function bindSettingsActions() {
1065
- document.getElementById('tier-select').addEventListener('change', (e) => {
1594
+ document.getElementById('tier-select').addEventListener('sl-change', (e) => {
1066
1595
  renderTierEditor(e.target.value);
1067
1596
  });
1068
1597
 
1069
1598
  document.getElementById('tier-form').addEventListener('submit', async (e) => {
1070
1599
  e.preventDefault();
1071
- const tierId = document.getElementById('tier-id').value;
1600
+ const tierId = document.getElementById('tier-select').value;
1601
+
1602
+ // A2A-41: collect tools from checkboxes
1603
+ const toolCheckboxes = document.querySelectorAll('#tier-tools-list sl-checkbox');
1604
+ const allowed_tools = Array.from(toolCheckboxes)
1605
+ .filter(cb => cb.checked)
1606
+ .map(cb => cb.value);
1607
+
1608
+ // A2A-41: collect topics from row elements
1609
+ const topicRows = document.querySelectorAll('#tier-topics-list .topic-row[data-topic]');
1610
+ const topics = Array.from(topicRows).map(row => row.dataset.topic).filter(Boolean);
1611
+ const manifestTopics = Array.from(topicRows).map(row => ({
1612
+ topic: row.dataset.topic,
1613
+ description: (row.querySelector('.topic-desc-edit')?.value || row.querySelector('.topic-desc-text')?.textContent || '').trim()
1614
+ })).filter(t => t.topic);
1615
+
1616
+ // A2A-41: collect goals from row elements. IMPORTANT: use 'topic' key (NOT
1617
+ // 'objective') because parseTopicObjects() in dashboard.js:160 only reads
1618
+ // entry.topic. The semantic distinction 'objective' vs 'topic' is UI-only;
1619
+ // the storage layer uses {topic, description} uniformly for both.
1620
+ const goalRows = document.querySelectorAll('#tier-goals-list .topic-row[data-topic]');
1621
+ const goals = Array.from(goalRows).map(row => row.dataset.topic).filter(Boolean);
1622
+ const manifestObjectives = Array.from(goalRows).map(row => ({
1623
+ topic: row.dataset.topic,
1624
+ description: (row.querySelector('.topic-desc-edit')?.value || row.querySelector('.topic-desc-text')?.textContent || '').trim()
1625
+ })).filter(g => g.topic);
1626
+
1072
1627
  const body = {
1073
1628
  name: document.getElementById('tier-name').value,
1074
1629
  description: document.getElementById('tier-description').value,
1075
- disclosure: document.getElementById('tier-disclosure').value,
1076
- allowed_tools: fromLines(document.getElementById('tier-tools').value),
1077
- topics: fromLines(document.getElementById('tier-topics').value),
1078
- goals: fromLines(document.getElementById('tier-goals').value)
1630
+ allowed_tools,
1631
+ topics,
1632
+ goals,
1633
+ manifest: {
1634
+ topics: manifestTopics,
1635
+ objectives: manifestObjectives
1636
+ }
1079
1637
  };
1080
1638
  await request(`/settings/tiers/${encodeURIComponent(tierId)}`, {
1081
1639
  method: 'PUT',
@@ -1086,7 +1644,7 @@ function bindSettingsActions() {
1086
1644
  });
1087
1645
 
1088
1646
  document.getElementById('copy-tier-btn').addEventListener('click', async () => {
1089
- const toTier = document.getElementById('tier-id').value;
1647
+ const toTier = document.getElementById('tier-select').value;
1090
1648
  const fromTier = document.getElementById('copy-from-tier').value;
1091
1649
  if (!toTier || !fromTier || toTier === fromTier) return;
1092
1650
  await request(`/settings/tiers/${encodeURIComponent(toTier)}/copy-from/${encodeURIComponent(fromTier)}`, {
@@ -1134,12 +1692,23 @@ function bindSettingsActions() {
1134
1692
  document.getElementById('tier-select').value = tierId;
1135
1693
  renderTierEditor(tierId);
1136
1694
  });
1695
+
1696
+ // A2A-41: toggle for three-column tier view
1697
+ document.getElementById('show-drag-columns')?.addEventListener('sl-change', () => {
1698
+ renderTierColumns();
1699
+ });
1700
+
1701
+ document.getElementById('preview-caller-btn')?.addEventListener('click', openCallerPreview);
1702
+ document.getElementById('preview-close-btn')?.addEventListener('click', () => {
1703
+ document.getElementById('preview-dialog').hide();
1704
+ });
1137
1705
  }
1138
1706
 
1139
1707
  async function loadSettings() {
1140
1708
  const payload = await request('/settings');
1141
1709
  state.settings = payload;
1142
1710
  fillTierSelects();
1711
+ renderTierColumns();
1143
1712
  document.getElementById('defaults-expiration').value = payload.defaults?.expiration || '7d';
1144
1713
  document.getElementById('defaults-max-calls').value = payload.defaults?.maxCalls || 100;
1145
1714
  }
@@ -1150,7 +1719,7 @@ function renderCallbookStatus() {
1150
1719
 
1151
1720
  const s = state.dashboardStatus;
1152
1721
  if (!s) {
1153
- el.textContent = 'Loading';
1722
+ el.textContent = 'Loading\u2026';
1154
1723
  return;
1155
1724
  }
1156
1725
 
@@ -1171,8 +1740,7 @@ function renderCallbookStatus() {
1171
1740
  const extMetaText = extMeta.length ? ` <span class="mono">(${esc(extMeta.join(', '))})</span>` : '';
1172
1741
  const extErrorText = ext && ext.error ? esc(ext.error) : '';
1173
1742
  const extAttemptsHtml = extAttempts.length
1174
- ? `<details style="margin-top:0.5rem;">
1175
- <summary>External IP probe</summary>
1743
+ ? `<sl-details summary="External IP probe" style="margin-top:0.5rem;">
1176
1744
  <div class="mono" style="margin-top:0.35rem;">
1177
1745
  ${extAttempts.map(a => {
1178
1746
  const service = a && a.service ? String(a.service) : '-';
@@ -1182,7 +1750,7 @@ function renderCallbookStatus() {
1182
1750
  return esc(`${service}: ${ok ? 'ok' + status : 'failed' + err}`);
1183
1751
  }).join('<br>')}
1184
1752
  </div>
1185
- </details>`
1753
+ </sl-details>`
1186
1754
  : '';
1187
1755
 
1188
1756
  el.innerHTML = `
@@ -1204,18 +1772,18 @@ function renderAutoUpdateStatus() {
1204
1772
 
1205
1773
  const au = state.autoUpdate;
1206
1774
  if (!au) {
1207
- el.textContent = 'Loading';
1775
+ el.textContent = 'Loading\u2026';
1208
1776
  if (toggleBtn) toggleBtn.disabled = true;
1209
1777
  return;
1210
1778
  }
1211
1779
 
1212
1780
  const stateText = formatUpdaterState(au.state);
1213
- const pillClass = updaterPillClass(au.state);
1781
+ const variant = badgeVariant(au.state);
1214
1782
  const enabled = Boolean(au.enabled);
1215
1783
  const intervalSec = Number.isFinite(au.interval_ms) ? Math.floor(au.interval_ms / 1000) : null;
1216
1784
 
1217
1785
  el.innerHTML = `
1218
- <div><strong>Status:</strong> <span class="status-pill ${pillClass}">${esc(stateText)}</span></div>
1786
+ <div><strong>Status:</strong> <sl-badge variant="${variant}">${esc(stateText)}</sl-badge></div>
1219
1787
  <div><strong>Enabled:</strong> ${enabled ? 'yes' : 'no'}</div>
1220
1788
  <div><strong>Current version:</strong> <span class="mono">${esc(au.current_version || '-')}</span></div>
1221
1789
  <div><strong>Latest version:</strong> <span class="mono">${esc(au.latest_version || '-')}</span></div>
@@ -1274,13 +1842,13 @@ function renderCallbookDevices() {
1274
1842
  <td>${esc(String(sessions))}</td>
1275
1843
  <td>${revoked ? esc(fmtDate(dev.revoked_at)) : '-'}</td>
1276
1844
  <td>
1277
- <button data-revoke="${esc(dev.id)}" ${revoked ? 'disabled' : ''}>Revoke</button>
1845
+ <sl-button size="small" variant="danger" data-revoke="${esc(dev.id)}" ${revoked ? 'disabled' : ''}>Revoke</sl-button>
1278
1846
  </td>
1279
1847
  `;
1280
1848
  tbody.appendChild(tr);
1281
1849
  });
1282
1850
 
1283
- tbody.querySelectorAll('button[data-revoke]').forEach(btn => {
1851
+ tbody.querySelectorAll('[data-revoke]').forEach(btn => {
1284
1852
  btn.addEventListener('click', async () => {
1285
1853
  const deviceId = btn.dataset.revoke;
1286
1854
  if (!deviceId) return;
@@ -1311,14 +1879,6 @@ function bindCallbookActions() {
1311
1879
  const labelEl = document.getElementById('callbook-label');
1312
1880
  const warningsEl = document.getElementById('callbook-warnings');
1313
1881
 
1314
- document.getElementById('callbook-refresh')?.addEventListener('click', () => {
1315
- Promise.all([loadDashboardStatus(true), loadCallbookDevices()]).catch(err => showNotice(err.message));
1316
- });
1317
-
1318
- document.getElementById('callbook-refresh-devices')?.addEventListener('click', () => {
1319
- loadCallbookDevices().catch(err => showNotice(err.message));
1320
- });
1321
-
1322
1882
  document.getElementById('callbook-logout')?.addEventListener('click', async () => {
1323
1883
  try {
1324
1884
  await request('/callbook/logout', { method: 'POST' });
@@ -1362,10 +1922,6 @@ function bindCallbookActions() {
1362
1922
  }
1363
1923
 
1364
1924
  function bindAutoUpdateActions() {
1365
- document.getElementById('auto-update-refresh')?.addEventListener('click', () => {
1366
- loadAutoUpdateStatus().catch(err => showNotice(err.message));
1367
- });
1368
-
1369
1925
  document.getElementById('auto-update-check')?.addEventListener('click', async () => {
1370
1926
  try {
1371
1927
  await request('/update/check', { method: 'POST', body: JSON.stringify({}) });
@@ -1413,13 +1969,13 @@ function renderInvites() {
1413
1969
  <td>${invite.tier || '-'}</td>
1414
1970
  <td>${invite.calls_made || 0}${invite.max_calls ? `/${invite.max_calls}` : ''}</td>
1415
1971
  <td>${fmtDate(invite.expires_at)}</td>
1416
- <td>${invite.revoked ? 'revoked' : 'active'}</td>
1417
- <td><button data-revoke="${invite.id}" ${invite.revoked ? 'disabled' : ''}>Revoke</button></td>
1972
+ <td><sl-badge variant="${invite.revoked ? 'danger' : 'success'}">${invite.revoked ? 'revoked' : 'active'}</sl-badge></td>
1973
+ <td><sl-button size="small" variant="danger" data-revoke="${invite.id}" ${invite.revoked ? 'disabled' : ''}>Revoke</sl-button></td>
1418
1974
  `;
1419
1975
  tbody.appendChild(tr);
1420
1976
  });
1421
1977
 
1422
- tbody.querySelectorAll('button[data-revoke]').forEach(btn => {
1978
+ tbody.querySelectorAll('[data-revoke]').forEach(btn => {
1423
1979
  btn.addEventListener('click', async () => {
1424
1980
  const tokenId = btn.dataset.revoke;
1425
1981
  await request(`/invites/${encodeURIComponent(tokenId)}/revoke`, { method: 'POST' });
@@ -1436,6 +1992,18 @@ async function loadInvites() {
1436
1992
  }
1437
1993
 
1438
1994
  function bindInviteActions() {
1995
+ const cancelBtn = document.getElementById('generate-invite-cancel');
1996
+ const inviteDetails = document.getElementById('generate-invite-details');
1997
+ const inviteMessageWrap = document.getElementById('invite-message-wrap');
1998
+ const inviteMessage = document.getElementById('invite-message');
1999
+
2000
+ // Cancel button collapses the sl-details
2001
+ if (cancelBtn && inviteDetails) {
2002
+ cancelBtn.addEventListener('click', () => {
2003
+ inviteDetails.open = false;
2004
+ });
2005
+ }
2006
+
1439
2007
  document.getElementById('invite-form').addEventListener('submit', async (e) => {
1440
2008
  e.preventDefault();
1441
2009
  const body = {
@@ -1450,7 +2018,13 @@ function bindInviteActions() {
1450
2018
  method: 'POST',
1451
2019
  body: JSON.stringify(body)
1452
2020
  });
1453
- document.getElementById('invite-message').value = result.invite_message || result.invite_url;
2021
+ // Show the invite message textarea with the result
2022
+ if (inviteMessage) {
2023
+ inviteMessage.value = result.invite_message || result.invite_url;
2024
+ if (inviteMessageWrap) inviteMessageWrap.style.display = 'block';
2025
+ }
2026
+ // Collapse the details after successful creation
2027
+ if (inviteDetails) inviteDetails.open = false;
1454
2028
  if (result.warnings && result.warnings.length) {
1455
2029
  showNotice(result.warnings[0]);
1456
2030
  } else {
@@ -1460,13 +2034,7 @@ function bindInviteActions() {
1460
2034
  });
1461
2035
  }
1462
2036
 
1463
- function bindRefreshButtons() {
1464
- document.getElementById('refresh-contacts').addEventListener('click', () => loadContacts().catch(err => showNotice(err.message)));
1465
- document.getElementById('refresh-calls').addEventListener('click', () => loadCalls().catch(err => showNotice(err.message)));
1466
- document.getElementById('refresh-invites').addEventListener('click', () => loadInvites().catch(err => showNotice(err.message)));
1467
- document.getElementById('refresh-logs').addEventListener('click', () => loadLogs().catch(err => showNotice(err.message)));
1468
- document.getElementById('refresh-log-stats').addEventListener('click', () => loadLogStats().catch(err => showNotice(err.message)));
1469
-
2037
+ function bindLogFilterRefresh() {
1470
2038
  // Auto-refresh logs as filters change (debounced).
1471
2039
  let debounce = null;
1472
2040
  const schedule = () => {
@@ -1485,19 +2053,64 @@ function bindRefreshButtons() {
1485
2053
  ].forEach(id => {
1486
2054
  const el = document.getElementById(id);
1487
2055
  if (!el) return;
2056
+ // Shoelace components fire sl-input and sl-change events
2057
+ el.addEventListener('sl-input', schedule);
2058
+ el.addEventListener('sl-change', schedule);
2059
+ // Also listen for native events as fallback
1488
2060
  el.addEventListener('input', schedule);
1489
2061
  el.addEventListener('change', schedule);
1490
2062
  });
1491
2063
  }
1492
2064
 
2065
+ // --- Smart tab polling ---
2066
+
2067
+ let pollTimer = null;
2068
+
2069
+ function getActiveTab() {
2070
+ const tabGroup = document.getElementById('main-tabs');
2071
+ if (!tabGroup) return 'contacts';
2072
+ // Shoelace tab group: find the active tab by checking which tab has the active attribute
2073
+ const activeTab = tabGroup.querySelector('sl-tab[active]');
2074
+ return activeTab ? activeTab.getAttribute('panel') : 'contacts';
2075
+ }
2076
+
2077
+ const tabLoaders = {
2078
+ contacts: loadContacts,
2079
+ calls: loadCalls,
2080
+ logs: () => { loadLogs(); loadLogStats(); },
2081
+ permissions: () => {},
2082
+ invites: loadInvites,
2083
+ };
2084
+
2085
+ function startPolling() {
2086
+ stopPolling();
2087
+ pollTimer = setInterval(() => {
2088
+ const loader = tabLoaders[getActiveTab()];
2089
+ if (loader) loader().catch(() => {});
2090
+ }, 5000);
2091
+ }
2092
+
2093
+ function stopPolling() {
2094
+ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
2095
+ }
2096
+
2097
+ function onTabSwitch(tabName) {
2098
+ const loader = tabLoaders[tabName];
2099
+ if (loader) {
2100
+ try { loader().catch(() => {}); } catch (_) {}
2101
+ }
2102
+ startPolling(); // reset the 5s timer
2103
+ }
2104
+
1493
2105
  async function bootstrap() {
1494
2106
  bindTabs();
1495
2107
  bindContactsActions();
1496
2108
  bindSettingsActions();
2109
+ bindItemListDelegation();
1497
2110
  bindCallbookActions();
1498
2111
  bindAutoUpdateActions();
1499
2112
  bindInviteActions();
1500
- bindRefreshButtons();
2113
+ bindLogFilterRefresh();
1501
2114
 
1502
2115
  try {
1503
2116
  await Promise.all([
@@ -1513,6 +2126,7 @@ async function bootstrap() {
1513
2126
  ]);
1514
2127
  showNotice('Dashboard loaded');
1515
2128
  connectRealtimeEvents();
2129
+ startPolling();
1516
2130
 
1517
2131
  setInterval(() => {
1518
2132
  loadAutoUpdateStatus().catch(() => {});