a2acalling 0.6.57 → 0.6.58

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,33 @@ 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
+ const hash = window.location.hash.slice(1);
265
+ if (hash) {
266
+ // Use try/catch in case the tab group isn't fully ready
267
+ try { tabGroup.show(hash); } catch (err) {}
268
+ }
269
+ };
270
+
271
+ window.addEventListener('hashchange', activateFromHash);
280
272
 
281
- // Deep-link into a tab with /dashboard/#logs, etc.
282
- activateTab(window.location.hash);
273
+ // On initial load, activate from hash (wait for Shoelace to be ready)
274
+ if (tabGroup.updateComplete) {
275
+ tabGroup.updateComplete.then(activateFromHash);
276
+ } else {
277
+ // Fallback: try after a short delay
278
+ setTimeout(activateFromHash, 100);
279
+ }
283
280
  }
284
281
 
285
282
  function norm(value) {
@@ -307,6 +304,35 @@ function contactLabel(contact) {
307
304
  return String(contact?.name || '').trim() || String(contact?.host || '').trim() || '-';
308
305
  }
309
306
 
307
+ function getPinnedContacts() {
308
+ try {
309
+ const raw = localStorage.getItem('a2a-pinned-contacts');
310
+ if (!raw) return [];
311
+ const parsed = JSON.parse(raw);
312
+ return Array.isArray(parsed) ? parsed : [];
313
+ } catch (err) {
314
+ return [];
315
+ }
316
+ }
317
+
318
+ function togglePin(contactId) {
319
+ const id = String(contactId || '');
320
+ if (!id) return;
321
+ const pinned = getPinnedContacts();
322
+ const index = pinned.indexOf(id);
323
+ if (index >= 0) {
324
+ pinned.splice(index, 1);
325
+ } else {
326
+ pinned.push(id);
327
+ }
328
+ try {
329
+ localStorage.setItem('a2a-pinned-contacts', JSON.stringify(pinned));
330
+ } catch (err) {
331
+ // localStorage may be unavailable
332
+ }
333
+ renderContacts();
334
+ }
335
+
310
336
  function renderContacts() {
311
337
  const el = document.getElementById('contacts-sections');
312
338
  if (!el) return;
@@ -318,30 +344,18 @@ function renderContacts() {
318
344
  .filter(c => isMine(c))
319
345
  .sort((a, b) => contactLabel(a).localeCompare(contactLabel(b)));
320
346
 
347
+ const pinnedIds = getPinnedContacts();
321
348
  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 || '')))
349
+ .filter(c => c && c.last_call_at && !isMine(c))
350
+ .sort((a, b) => {
351
+ const aPinned = pinnedIds.includes(String(a.id));
352
+ const bPinned = pinnedIds.includes(String(b.id));
353
+ if (aPinned && !bPinned) return -1;
354
+ if (!aPinned && bPinned) return 1;
355
+ return String(b.last_call_at || '').localeCompare(String(a.last_call_at || ''));
356
+ })
324
357
  .slice(0, 12);
325
358
 
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
359
  const rowHtml = (c, opts = {}) => {
346
360
  const canCall = Boolean(c?.can_call);
347
361
  const mine = Boolean(c?.is_mine);
@@ -350,13 +364,17 @@ function renderContacts() {
350
364
  const lastCallAt = c?.last_call_at ? fmtDate(c.last_call_at) : '-';
351
365
  const calls = Number.isFinite(c?.call_count) ? c.call_count : (c?.call_count || 0);
352
366
  const isSelected = selected && String(c?.id) === selected;
367
+ const isPinned = pinnedIds.includes(String(c?.id));
353
368
 
354
369
  const actionBits = [];
370
+ if (opts.showPin) {
371
+ 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>`);
372
+ }
355
373
  if (c?.last_call_id) {
356
- actionBits.push(`<button data-open-call="${esc(c.last_call_id)}" type="button">Transcript</button>`);
374
+ actionBits.push(`<sl-button size="small" data-open-call="${esc(c.last_call_id)}">Transcript</sl-button>`);
357
375
  }
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>`);
376
+ actionBits.push(`<sl-button size="small" data-toggle-mine="${esc(c.id)}">${mine ? 'Unmark mine' : 'Mark mine'}</sl-button>`);
377
+ actionBits.push(`<sl-button size="small" variant="danger" data-remove-contact="${esc(c.id)}">Remove</sl-button>`);
360
378
 
361
379
  const locationCell = opts.showLocation ? `<td>${esc(formatLocation(c))}</td>` : '';
362
380
  const ownerCell = opts.showOwner ? `<td>${esc(c?.owner || '-')}</td>` : '';
@@ -366,8 +384,8 @@ function renderContacts() {
366
384
  <tr ${isSelected ? 'data-selected="1"' : ''}>
367
385
  <td>
368
386
  <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>
387
+ <sl-button variant="text" size="small" data-contact-select="${esc(c.id)}">${esc(contactLabel(c))}</sl-button>
388
+ <sl-button size="small" variant="primary" data-contact-call="${esc(c.id)}" ${canCall ? '' : 'disabled'}>Call</sl-button>
371
389
  </div>
372
390
  </td>
373
391
  ${locationCell}
@@ -400,30 +418,48 @@ function renderContacts() {
400
418
  };
401
419
 
402
420
  const myAgentsSection = `
403
- <div class="card">
421
+ <sl-card>
404
422
  <h3>My agents</h3>
405
423
  ${tableHtml(myAgents, { showLocation: true, showOwner: false, showSummary: false })}
406
- </div>
424
+ </sl-card>
407
425
  `;
408
426
 
409
427
  const lastCalledSection = `
410
- <div class="card">
428
+ <sl-card>
411
429
  <h3>Last called agents</h3>
412
- ${tableHtml(lastCalled, { showLocation: false, showOwner: true, showSummary: false })}
413
- </div>
430
+ ${tableHtml(lastCalled, { showLocation: false, showOwner: true, showSummary: false, showPin: true })}
431
+ </sl-card>
414
432
  `;
415
433
 
416
- const groupedSections = owners.map(owner => {
417
- const rows = (groups.get(owner) || []).slice().sort((a, b) => contactLabel(a).localeCompare(contactLabel(b)));
434
+ const otherContacts = contacts.filter(c => !isMine(c));
435
+ const otherGroups = new Map();
436
+ for (const c of otherContacts) {
437
+ const owner = String(c?.owner || '').trim() || '(unknown owner)';
438
+ if (!otherGroups.has(owner)) otherGroups.set(owner, []);
439
+ otherGroups.get(owner).push(c);
440
+ }
441
+
442
+ const otherOwners = Array.from(otherGroups.keys()).sort((a, b) => {
443
+ if (a === '(unknown owner)' && b !== '(unknown owner)') return 1;
444
+ if (a !== '(unknown owner)' && b === '(unknown owner)') return -1;
445
+ return a.localeCompare(b);
446
+ });
447
+
448
+ const groupedSections = otherOwners.map(owner => {
449
+ const rows = (otherGroups.get(owner) || []).slice().sort((a, b) => contactLabel(a).localeCompare(contactLabel(b)));
418
450
  return `
419
- <div class="card">
451
+ <sl-card>
420
452
  <h3>${esc(owner)}</h3>
421
453
  ${tableHtml(rows, { showLocation: false, showOwner: false, showSummary: true })}
422
- </div>
454
+ </sl-card>
423
455
  `;
424
456
  }).join('');
425
457
 
426
- el.innerHTML = `${myAgentsSection}${lastCalledSection}${groupedSections}`;
458
+ const otherAgentsHeading = otherOwners.length
459
+ ? `<h3 style="margin-top:1rem;">Other Agents</h3>`
460
+ : '';
461
+
462
+ el.innerHTML = `${myAgentsSection}${lastCalledSection}${otherAgentsHeading}${groupedSections}`;
427
463
  }
428
464
 
429
465
  async function loadContacts() {
@@ -437,6 +473,15 @@ function bindContactsActions() {
437
473
  const form = document.getElementById('add-contact-form');
438
474
  if (!form) return;
439
475
 
476
+ // Cancel button collapses the sl-details
477
+ const cancelBtn = document.getElementById('add-contact-cancel');
478
+ const addDetails = document.getElementById('add-contact-details');
479
+ if (cancelBtn && addDetails) {
480
+ cancelBtn.addEventListener('click', () => {
481
+ addDetails.open = false;
482
+ });
483
+ }
484
+
440
485
  const urlEl = document.getElementById('add-contact-url');
441
486
  const mineEl = document.getElementById('add-contact-mine');
442
487
  const serverNameEl = document.getElementById('add-contact-server-name');
@@ -449,9 +494,9 @@ function bindContactsActions() {
449
494
  serverNameEl.value = match[1];
450
495
  }
451
496
  };
452
- urlEl?.addEventListener('blur', defaultServerNameFromUrl);
453
- urlEl?.addEventListener('change', defaultServerNameFromUrl);
454
- mineEl?.addEventListener('change', () => {
497
+ urlEl?.addEventListener('sl-blur', defaultServerNameFromUrl);
498
+ urlEl?.addEventListener('sl-change', defaultServerNameFromUrl);
499
+ mineEl?.addEventListener('sl-change', () => {
455
500
  if (!serverNameEl) return;
456
501
  serverNameEl.disabled = !mineEl.checked;
457
502
  if (mineEl.checked) {
@@ -467,7 +512,7 @@ function bindContactsActions() {
467
512
  const url = document.getElementById('add-contact-url').value.trim();
468
513
  const name = document.getElementById('add-contact-name').value.trim();
469
514
  const owner = document.getElementById('add-contact-owner').value.trim();
470
- const isMine = Boolean(document.getElementById('add-contact-mine')?.checked);
515
+ const isMineVal = Boolean(document.getElementById('add-contact-mine')?.checked);
471
516
  const serverName = document.getElementById('add-contact-server-name').value.trim();
472
517
  const tagsRaw = document.getElementById('add-contact-tags').value.trim();
473
518
  const notes = document.getElementById('add-contact-notes').value.trim();
@@ -497,7 +542,7 @@ function bindContactsActions() {
497
542
  invite_url: url,
498
543
  name: name || undefined,
499
544
  owner: owner || undefined,
500
- is_mine: isMine,
545
+ is_mine: isMineVal,
501
546
  server_name: serverName || undefined,
502
547
  tags,
503
548
  notes: notes || undefined,
@@ -506,15 +551,26 @@ function bindContactsActions() {
506
551
  });
507
552
  showNotice('Contact added');
508
553
  form.reset();
554
+ // Collapse the sl-details after successful add
555
+ if (addDetails) addDetails.open = false;
509
556
  await loadContacts();
510
557
  } catch (err) {
511
558
  showNotice(err.message);
512
559
  }
513
560
  });
514
561
 
515
- const panel = document.getElementById('tab-contacts');
562
+ // Event delegation on the contacts tab panel
563
+ const panel = document.querySelector('sl-tab-panel[name="contacts"]');
516
564
  panel?.addEventListener('click', async (e) => {
517
- const selectBtn = e.target.closest('button[data-contact-select]');
565
+ const pinBtn = e.target.closest('[data-pin-contact]');
566
+ if (pinBtn) {
567
+ e.preventDefault();
568
+ const id = pinBtn.dataset.pinContact;
569
+ if (id) togglePin(id);
570
+ return;
571
+ }
572
+
573
+ const selectBtn = e.target.closest('[data-contact-select]');
518
574
  if (selectBtn) {
519
575
  e.preventDefault();
520
576
  const id = selectBtn.dataset.contactSelect;
@@ -524,14 +580,14 @@ function bindContactsActions() {
524
580
  return;
525
581
  }
526
582
 
527
- const openBtn = e.target.closest('button[data-open-call]');
583
+ const openBtn = e.target.closest('[data-open-call]');
528
584
  if (openBtn) {
529
585
  e.preventDefault();
530
586
  openCallTranscript(openBtn.dataset.openCall);
531
587
  return;
532
588
  }
533
589
 
534
- const mineBtn = e.target.closest('button[data-toggle-mine]');
590
+ const mineBtn = e.target.closest('[data-toggle-mine]');
535
591
  if (mineBtn) {
536
592
  e.preventDefault();
537
593
  const id = mineBtn.dataset.toggleMine;
@@ -558,7 +614,7 @@ function bindContactsActions() {
558
614
  return;
559
615
  }
560
616
 
561
- const removeBtn = e.target.closest('button[data-remove-contact]');
617
+ const removeBtn = e.target.closest('[data-remove-contact]');
562
618
  if (removeBtn) {
563
619
  e.preventDefault();
564
620
  const id = removeBtn.dataset.removeContact;
@@ -580,7 +636,7 @@ function bindContactsActions() {
580
636
  return;
581
637
  }
582
638
 
583
- const callBtn = e.target.closest('button[data-contact-call]');
639
+ const callBtn = e.target.closest('[data-contact-call]');
584
640
  if (callBtn) {
585
641
  e.preventDefault();
586
642
  const id = callBtn.dataset.contactCall;
@@ -645,15 +701,35 @@ async function loadCallDetail(conversationId) {
645
701
  const payload = await request(`/calls/${encodeURIComponent(conversationId)}?messages=40`);
646
702
  const call = payload.call;
647
703
  const el = document.getElementById('call-detail');
648
- const messages = (call.recentMessages || [])
649
- .map(msg => `[${fmtDate(msg.timestamp)}] ${msg.direction}: ${msg.content}`)
704
+
705
+ // Summary: prefer agent-generated summary, fall back to owner summary
706
+ const summaryText = call.summary || call.ownerContext?.summary || '';
707
+ const summaryHtml = summaryText
708
+ ? `<pre class="summary">${esc(summaryText)}</pre>`
709
+ : `<p class="summary-pending"><em>${call.status === 'active' ? 'Call in progress\u2026' : 'Summary pending\u2026'}</em></p>`;
710
+
711
+ // Full transcript in a collapsible section
712
+ const messages = (call.recentMessages || []);
713
+ const transcriptLines = messages
714
+ .map(msg => `[${esc(fmtDate(msg.timestamp))}] ${esc(msg.direction)}: ${esc(msg.content)}`)
650
715
  .join('\n\n');
716
+ const totalMessages = call.messageCount || messages.length;
717
+ const countLabel = messages.length < totalMessages
718
+ ? `${messages.length} of ${totalMessages} messages`
719
+ : `${messages.length} message${messages.length === 1 ? '' : 's'}`;
720
+ const transcriptHtml = messages.length
721
+ ? `<sl-details class="transcript-details" summary="Full Transcript (${countLabel})">
722
+ <pre class="transcript">${transcriptLines}</pre>
723
+ </sl-details>`
724
+ : '';
725
+
651
726
  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>
727
+ <h3>Call Detail: ${esc(call.id)}</h3>
728
+ <p><strong>Contact:</strong> ${esc(call.contact?.name || call.contact || '-')}</p>
729
+ <p><strong>Status:</strong> ${esc(call.status || '-')}</p>
730
+ <p><strong>Summary:</strong></p>
731
+ ${summaryHtml}
732
+ ${transcriptHtml}
657
733
  `;
658
734
  }
659
735
 
@@ -690,7 +766,7 @@ function renderContactDetail() {
690
766
  const resultHtml = result
691
767
  ? `<div style="margin-top:0.6rem;">
692
768
  <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>` : ''}
769
+ ${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
770
  ${result.error ? `<span class="mono">${esc(result.error)}</span><br>` : ''}
695
771
  ${result.response ? `<pre class="summary">${esc(String(result.response))}</pre>` : ''}
696
772
  </div>`
@@ -705,7 +781,7 @@ function renderContactDetail() {
705
781
  <td>${esc(call.status || '-')}</td>
706
782
  <td>${esc(fmtDate(call.last_message_at))}</td>
707
783
  <td title="${esc(summary)}">${esc(preview)}</td>
708
- <td><button data-open-call="${esc(call.id)}" type="button">Transcript</button></td>
784
+ <td><sl-button size="small" data-open-call="${esc(call.id)}">Transcript</sl-button></td>
709
785
  </tr>
710
786
  `;
711
787
  }).join('');
@@ -713,16 +789,16 @@ function renderContactDetail() {
713
789
  el.innerHTML = `
714
790
  <div class="row">
715
791
  <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>
792
+ <sl-button size="small" variant="primary" data-contact-call="${esc(contact.id)}" ${canCall ? '' : 'disabled'}>Call</sl-button>
793
+ <sl-button size="small" variant="danger" data-remove-contact="${esc(contact.id)}">Remove</sl-button>
718
794
  </div>
719
795
 
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>
796
+ <div class="row" style="margin-bottom:0.4rem;">
797
+ <div><strong>Mine:</strong> ${contact.is_mine ? 'yes' : 'no'}</div>
798
+ <div><strong>Owner:</strong> ${esc(contact.owner || '-')}</div>
799
+ <div><strong>Web address:</strong> <span class="mono">${esc(contact.web_address || contact.host || '-')}</span></div>
800
+ <div><strong>Server name:</strong> ${esc(contact.server_name || '-')}</div>
801
+ </div>
726
802
  <div class="row">
727
803
  <div><strong>Status:</strong> ${esc(contact.status || '-')}</div>
728
804
  <div><strong>Total calls:</strong> ${esc(String(contact.call_count || 0))}</div>
@@ -731,48 +807,45 @@ function renderContactDetail() {
731
807
 
732
808
  ${resultHtml}
733
809
 
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>
810
+ <sl-details summary="Edit contact" open style="margin-top:0.8rem;">
811
+ <form id="contact-edit-form" data-contact-id="${esc(contact.id)}" style="margin-top:0.6rem;">
812
+ <sl-input id="contact-edit-name" label="Agent name" value="${esc(contact.name || '')}"></sl-input>
813
+ <sl-input id="contact-edit-owner" label="Owner name" value="${esc(contact.owner || '')}"></sl-input>
814
+ <sl-checkbox id="contact-edit-mine" ${contact.is_mine ? 'checked' : ''}>Mark as mine (personal agent)</sl-checkbox>
815
+ <sl-input id="contact-edit-server-name" label="Server name (my agents only)" value="${esc(contact.server_name || '')}" ${contact.is_mine ? '' : 'disabled'}></sl-input>
816
+ <sl-input id="contact-edit-tags" label="Tags" value="${esc(tagsText)}" placeholder="comma,separated"></sl-input>
817
+ <sl-textarea id="contact-edit-notes" label="Notes" rows="3" value="${esc(contact.notes || '')}"></sl-textarea>
818
+ <sl-textarea id="contact-edit-fields" label="Fields (JSON)" rows="5" value="${esc(fieldsText)}"></sl-textarea>
819
+ <div class="row">
820
+ <sl-button type="submit" variant="primary" size="small">Save</sl-button>
821
+ </div>
822
+ </form>
823
+ </sl-details>
824
+
825
+ <sl-details summary="Call" open style="margin-top:0.8rem;">
752
826
  <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>
827
+ <sl-textarea id="contact-call-message" label="Message" rows="4" placeholder="Message to send"></sl-textarea>
754
828
  <div class="row">
755
- <button type="submit" ${canCall ? '' : 'disabled'}>Call</button>
829
+ <sl-button type="submit" variant="primary" size="small" ${canCall ? '' : 'disabled'}>Call</sl-button>
756
830
  </div>
757
831
  </form>
758
- </details>
832
+ </sl-details>
759
833
 
760
- <details style="margin-top:0.8rem;">
761
- <summary><strong>Call history</strong></summary>
834
+ <sl-details summary="Call history" style="margin-top:0.8rem;">
762
835
  <div style="margin-top:0.6rem;">
763
836
  <table>
764
837
  <thead><tr><th>ID</th><th>Status</th><th>Updated</th><th>Summary</th><th>Action</th></tr></thead>
765
838
  <tbody>${callRows || '<tr><td colspan="5">No calls found.</td></tr>'}</tbody>
766
839
  </table>
767
840
  </div>
768
- </details>
841
+ </sl-details>
769
842
  `;
770
843
 
771
844
  const editForm = document.getElementById('contact-edit-form');
772
845
  if (editForm) {
773
846
  const mineEl = document.getElementById('contact-edit-mine');
774
847
  const serverNameEl = document.getElementById('contact-edit-server-name');
775
- mineEl?.addEventListener('change', () => {
848
+ mineEl?.addEventListener('sl-change', () => {
776
849
  if (!serverNameEl) return;
777
850
  serverNameEl.disabled = !mineEl.checked;
778
851
  });
@@ -802,19 +875,19 @@ function renderContactDetail() {
802
875
  }
803
876
  }
804
877
 
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
- });
878
+ try {
879
+ await request(`/contacts/${encodeURIComponent(id)}`, {
880
+ method: 'PUT',
881
+ body: JSON.stringify({
882
+ name: document.getElementById('contact-edit-name').value,
883
+ owner: document.getElementById('contact-edit-owner').value,
884
+ is_mine: Boolean(document.getElementById('contact-edit-mine')?.checked),
885
+ server_name: document.getElementById('contact-edit-server-name').value,
886
+ notes: document.getElementById('contact-edit-notes').value,
887
+ tags,
888
+ fields
889
+ })
890
+ });
818
891
  showNotice('Contact saved');
819
892
  await loadContacts();
820
893
  await loadCallsForContact(id);
@@ -933,10 +1006,10 @@ function renderLogStats() {
933
1006
  <strong>Total:</strong> ${stats.total || 0}
934
1007
  </div>
935
1008
  <div class="row">
936
- <strong>By level:</strong> ${levels.map(([k, v]) => `${esc(k)}=${v}`).join(' · ') || '(none)'}
1009
+ <strong>By level:</strong> ${levels.map(([k, v]) => `${esc(k)}=${v}`).join(' \u00b7 ') || '(none)'}
937
1010
  </div>
938
1011
  <div class="row">
939
- <strong>Top components:</strong> ${components.map(([k, v]) => `${esc(k)}=${v}`).join(' · ') || '(none)'}
1012
+ <strong>Top components:</strong> ${components.map(([k, v]) => `${esc(k)}=${v}`).join(' \u00b7 ') || '(none)'}
940
1013
  </div>
941
1014
  `;
942
1015
  }
@@ -964,7 +1037,7 @@ function renderTraceDetail() {
964
1037
  el.innerHTML = `
965
1038
  <div class="row">
966
1039
  <h3 style="margin:0;">Trace: <span class="mono">${esc(state.trace.trace_id || '')}</span></h3>
967
- <button id="clear-trace">Clear</button>
1040
+ <sl-button id="clear-trace" size="small">Clear</sl-button>
968
1041
  </div>
969
1042
  <pre class="summary mono">${esc(lines || 'No trace logs.')}</pre>
970
1043
  `;
@@ -990,8 +1063,8 @@ function renderLogs() {
990
1063
  <td>${esc(row.component || '-')}</td>
991
1064
  <td>${esc(row.event || '-')}</td>
992
1065
  <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>
1066
+ <td class="mono">${esc(trace ? trace.slice(0, 14) + '\u2026' : '-')}</td>
1067
+ <td class="mono">${esc(row.conversation_id ? row.conversation_id.slice(0, 14) + '\u2026' : '-')}</td>
995
1068
  <td class="mono">${esc(row.token_id || '-')}</td>
996
1069
  <td>${esc(row.error_code || '-')}</td>
997
1070
  <td>${esc(row.status_code ?? '-')}</td>
@@ -1029,16 +1102,15 @@ function fillTierSelects() {
1029
1102
  const newTierCopy = document.getElementById('new-tier-copy-from');
1030
1103
  const inviteTier = document.getElementById('invite-tier');
1031
1104
 
1032
- [tierSelect, copyFrom, inviteTier].forEach(el => { el.innerHTML = ''; });
1033
- newTierCopy.innerHTML = '<option value="">None</option>';
1105
+ // Build options HTML for sl-select elements
1106
+ const optionsHtml = tiers.map(tier =>
1107
+ `<sl-option value="${esc(tier.id)}">${esc(tier.id)} (${esc(tier.name || tier.id)})</sl-option>`
1108
+ ).join('');
1034
1109
 
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
- });
1110
+ tierSelect.innerHTML = optionsHtml;
1111
+ copyFrom.innerHTML = optionsHtml;
1112
+ inviteTier.innerHTML = optionsHtml;
1113
+ newTierCopy.innerHTML = `<sl-option value="">None</sl-option>${optionsHtml}`;
1042
1114
 
1043
1115
  if (tiers.length > 0) {
1044
1116
  tierSelect.value = tiers[0].id;
@@ -1062,7 +1134,7 @@ function renderTierEditor(tierId) {
1062
1134
  }
1063
1135
 
1064
1136
  function bindSettingsActions() {
1065
- document.getElementById('tier-select').addEventListener('change', (e) => {
1137
+ document.getElementById('tier-select').addEventListener('sl-change', (e) => {
1066
1138
  renderTierEditor(e.target.value);
1067
1139
  });
1068
1140
 
@@ -1150,7 +1222,7 @@ function renderCallbookStatus() {
1150
1222
 
1151
1223
  const s = state.dashboardStatus;
1152
1224
  if (!s) {
1153
- el.textContent = 'Loading';
1225
+ el.textContent = 'Loading\u2026';
1154
1226
  return;
1155
1227
  }
1156
1228
 
@@ -1171,8 +1243,7 @@ function renderCallbookStatus() {
1171
1243
  const extMetaText = extMeta.length ? ` <span class="mono">(${esc(extMeta.join(', '))})</span>` : '';
1172
1244
  const extErrorText = ext && ext.error ? esc(ext.error) : '';
1173
1245
  const extAttemptsHtml = extAttempts.length
1174
- ? `<details style="margin-top:0.5rem;">
1175
- <summary>External IP probe</summary>
1246
+ ? `<sl-details summary="External IP probe" style="margin-top:0.5rem;">
1176
1247
  <div class="mono" style="margin-top:0.35rem;">
1177
1248
  ${extAttempts.map(a => {
1178
1249
  const service = a && a.service ? String(a.service) : '-';
@@ -1182,7 +1253,7 @@ function renderCallbookStatus() {
1182
1253
  return esc(`${service}: ${ok ? 'ok' + status : 'failed' + err}`);
1183
1254
  }).join('<br>')}
1184
1255
  </div>
1185
- </details>`
1256
+ </sl-details>`
1186
1257
  : '';
1187
1258
 
1188
1259
  el.innerHTML = `
@@ -1204,18 +1275,18 @@ function renderAutoUpdateStatus() {
1204
1275
 
1205
1276
  const au = state.autoUpdate;
1206
1277
  if (!au) {
1207
- el.textContent = 'Loading';
1278
+ el.textContent = 'Loading\u2026';
1208
1279
  if (toggleBtn) toggleBtn.disabled = true;
1209
1280
  return;
1210
1281
  }
1211
1282
 
1212
1283
  const stateText = formatUpdaterState(au.state);
1213
- const pillClass = updaterPillClass(au.state);
1284
+ const variant = badgeVariant(au.state);
1214
1285
  const enabled = Boolean(au.enabled);
1215
1286
  const intervalSec = Number.isFinite(au.interval_ms) ? Math.floor(au.interval_ms / 1000) : null;
1216
1287
 
1217
1288
  el.innerHTML = `
1218
- <div><strong>Status:</strong> <span class="status-pill ${pillClass}">${esc(stateText)}</span></div>
1289
+ <div><strong>Status:</strong> <sl-badge variant="${variant}">${esc(stateText)}</sl-badge></div>
1219
1290
  <div><strong>Enabled:</strong> ${enabled ? 'yes' : 'no'}</div>
1220
1291
  <div><strong>Current version:</strong> <span class="mono">${esc(au.current_version || '-')}</span></div>
1221
1292
  <div><strong>Latest version:</strong> <span class="mono">${esc(au.latest_version || '-')}</span></div>
@@ -1274,13 +1345,13 @@ function renderCallbookDevices() {
1274
1345
  <td>${esc(String(sessions))}</td>
1275
1346
  <td>${revoked ? esc(fmtDate(dev.revoked_at)) : '-'}</td>
1276
1347
  <td>
1277
- <button data-revoke="${esc(dev.id)}" ${revoked ? 'disabled' : ''}>Revoke</button>
1348
+ <sl-button size="small" variant="danger" data-revoke="${esc(dev.id)}" ${revoked ? 'disabled' : ''}>Revoke</sl-button>
1278
1349
  </td>
1279
1350
  `;
1280
1351
  tbody.appendChild(tr);
1281
1352
  });
1282
1353
 
1283
- tbody.querySelectorAll('button[data-revoke]').forEach(btn => {
1354
+ tbody.querySelectorAll('[data-revoke]').forEach(btn => {
1284
1355
  btn.addEventListener('click', async () => {
1285
1356
  const deviceId = btn.dataset.revoke;
1286
1357
  if (!deviceId) return;
@@ -1311,14 +1382,6 @@ function bindCallbookActions() {
1311
1382
  const labelEl = document.getElementById('callbook-label');
1312
1383
  const warningsEl = document.getElementById('callbook-warnings');
1313
1384
 
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
1385
  document.getElementById('callbook-logout')?.addEventListener('click', async () => {
1323
1386
  try {
1324
1387
  await request('/callbook/logout', { method: 'POST' });
@@ -1362,10 +1425,6 @@ function bindCallbookActions() {
1362
1425
  }
1363
1426
 
1364
1427
  function bindAutoUpdateActions() {
1365
- document.getElementById('auto-update-refresh')?.addEventListener('click', () => {
1366
- loadAutoUpdateStatus().catch(err => showNotice(err.message));
1367
- });
1368
-
1369
1428
  document.getElementById('auto-update-check')?.addEventListener('click', async () => {
1370
1429
  try {
1371
1430
  await request('/update/check', { method: 'POST', body: JSON.stringify({}) });
@@ -1413,13 +1472,13 @@ function renderInvites() {
1413
1472
  <td>${invite.tier || '-'}</td>
1414
1473
  <td>${invite.calls_made || 0}${invite.max_calls ? `/${invite.max_calls}` : ''}</td>
1415
1474
  <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>
1475
+ <td><sl-badge variant="${invite.revoked ? 'danger' : 'success'}">${invite.revoked ? 'revoked' : 'active'}</sl-badge></td>
1476
+ <td><sl-button size="small" variant="danger" data-revoke="${invite.id}" ${invite.revoked ? 'disabled' : ''}>Revoke</sl-button></td>
1418
1477
  `;
1419
1478
  tbody.appendChild(tr);
1420
1479
  });
1421
1480
 
1422
- tbody.querySelectorAll('button[data-revoke]').forEach(btn => {
1481
+ tbody.querySelectorAll('[data-revoke]').forEach(btn => {
1423
1482
  btn.addEventListener('click', async () => {
1424
1483
  const tokenId = btn.dataset.revoke;
1425
1484
  await request(`/invites/${encodeURIComponent(tokenId)}/revoke`, { method: 'POST' });
@@ -1436,6 +1495,18 @@ async function loadInvites() {
1436
1495
  }
1437
1496
 
1438
1497
  function bindInviteActions() {
1498
+ const cancelBtn = document.getElementById('generate-invite-cancel');
1499
+ const inviteDetails = document.getElementById('generate-invite-details');
1500
+ const inviteMessageWrap = document.getElementById('invite-message-wrap');
1501
+ const inviteMessage = document.getElementById('invite-message');
1502
+
1503
+ // Cancel button collapses the sl-details
1504
+ if (cancelBtn && inviteDetails) {
1505
+ cancelBtn.addEventListener('click', () => {
1506
+ inviteDetails.open = false;
1507
+ });
1508
+ }
1509
+
1439
1510
  document.getElementById('invite-form').addEventListener('submit', async (e) => {
1440
1511
  e.preventDefault();
1441
1512
  const body = {
@@ -1450,7 +1521,13 @@ function bindInviteActions() {
1450
1521
  method: 'POST',
1451
1522
  body: JSON.stringify(body)
1452
1523
  });
1453
- document.getElementById('invite-message').value = result.invite_message || result.invite_url;
1524
+ // Show the invite message textarea with the result
1525
+ if (inviteMessage) {
1526
+ inviteMessage.value = result.invite_message || result.invite_url;
1527
+ if (inviteMessageWrap) inviteMessageWrap.style.display = 'block';
1528
+ }
1529
+ // Collapse the details after successful creation
1530
+ if (inviteDetails) inviteDetails.open = false;
1454
1531
  if (result.warnings && result.warnings.length) {
1455
1532
  showNotice(result.warnings[0]);
1456
1533
  } else {
@@ -1460,13 +1537,7 @@ function bindInviteActions() {
1460
1537
  });
1461
1538
  }
1462
1539
 
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
-
1540
+ function bindLogFilterRefresh() {
1470
1541
  // Auto-refresh logs as filters change (debounced).
1471
1542
  let debounce = null;
1472
1543
  const schedule = () => {
@@ -1485,11 +1556,55 @@ function bindRefreshButtons() {
1485
1556
  ].forEach(id => {
1486
1557
  const el = document.getElementById(id);
1487
1558
  if (!el) return;
1559
+ // Shoelace components fire sl-input and sl-change events
1560
+ el.addEventListener('sl-input', schedule);
1561
+ el.addEventListener('sl-change', schedule);
1562
+ // Also listen for native events as fallback
1488
1563
  el.addEventListener('input', schedule);
1489
1564
  el.addEventListener('change', schedule);
1490
1565
  });
1491
1566
  }
1492
1567
 
1568
+ // --- Smart tab polling ---
1569
+
1570
+ let pollTimer = null;
1571
+
1572
+ function getActiveTab() {
1573
+ const tabGroup = document.getElementById('main-tabs');
1574
+ if (!tabGroup) return 'contacts';
1575
+ // Shoelace tab group: find the active tab by checking which tab has the active attribute
1576
+ const activeTab = tabGroup.querySelector('sl-tab[active]');
1577
+ return activeTab ? activeTab.getAttribute('panel') : 'contacts';
1578
+ }
1579
+
1580
+ const tabLoaders = {
1581
+ contacts: loadContacts,
1582
+ calls: loadCalls,
1583
+ logs: () => { loadLogs(); loadLogStats(); },
1584
+ settings: () => {},
1585
+ invites: loadInvites,
1586
+ };
1587
+
1588
+ function startPolling() {
1589
+ stopPolling();
1590
+ pollTimer = setInterval(() => {
1591
+ const loader = tabLoaders[getActiveTab()];
1592
+ if (loader) loader().catch(() => {});
1593
+ }, 5000);
1594
+ }
1595
+
1596
+ function stopPolling() {
1597
+ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
1598
+ }
1599
+
1600
+ function onTabSwitch(tabName) {
1601
+ const loader = tabLoaders[tabName];
1602
+ if (loader) {
1603
+ try { loader().catch(() => {}); } catch (_) {}
1604
+ }
1605
+ startPolling(); // reset the 5s timer
1606
+ }
1607
+
1493
1608
  async function bootstrap() {
1494
1609
  bindTabs();
1495
1610
  bindContactsActions();
@@ -1497,7 +1612,7 @@ async function bootstrap() {
1497
1612
  bindCallbookActions();
1498
1613
  bindAutoUpdateActions();
1499
1614
  bindInviteActions();
1500
- bindRefreshButtons();
1615
+ bindLogFilterRefresh();
1501
1616
 
1502
1617
  try {
1503
1618
  await Promise.all([
@@ -1513,6 +1628,7 @@ async function bootstrap() {
1513
1628
  ]);
1514
1629
  showNotice('Dashboard loaded');
1515
1630
  connectRealtimeEvents();
1631
+ startPolling();
1516
1632
 
1517
1633
  setInterval(() => {
1518
1634
  loadAutoUpdateStatus().catch(() => {});