a2acalling 0.6.64 → 0.6.66

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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.6.64",
3
- "installed_at": "2026-02-21T20:17:36.374Z",
2
+ "version": "0.6.66",
3
+ "installed_at": "2026-02-25T09:43:06.371Z",
4
4
  "files": [
5
5
  {
6
6
  "path": "CLAUDE.md",
package/CONVENTIONS.md CHANGED
@@ -63,6 +63,9 @@ All modules use CommonJS (`require`/`module.exports`). Each lib file exports a f
63
63
  - Uses Shoelace web components (`<sl-*>` elements)
64
64
  - Communicates via fetch to `/dashboard/api/*` endpoints
65
65
  - SSE for real-time updates via `src/lib/dashboard-events.js`
66
+ - Dark theme is the default; uses CSS custom properties for theming
67
+ - Sidebar navigation with tab switching (Contacts, Calls, Invites, Logs, Settings, Permissions, Health)
68
+ - Permissions tab uses tier cards with tool toggles and auto-save
66
69
 
67
70
  ## Permission Tiers
68
71
 
package/bin/cli.js CHANGED
@@ -1103,17 +1103,18 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1103
1103
  console.log('Legend: 🌐 public šŸ”§ friends ⚔ family');
1104
1104
  },
1105
1105
 
1106
- 'contacts:add': (args) => {
1106
+ 'contacts:add': async (args) => {
1107
1107
  const url = args._[2];
1108
1108
  if (!url) {
1109
1109
  console.error('Usage: a2a contacts add <invite_url> [options]');
1110
1110
  console.error('Options:');
1111
- console.error(' --name, -n Agent name');
1112
- console.error(' --owner, -o Owner name');
1113
- console.error(' --server-name Server label (optional)');
1114
- console.error(' --notes Notes about this contact');
1115
- console.error(' --tags Comma-separated tags');
1116
- console.error(' --link Link to token ID you gave them');
1111
+ console.error(' --name, -n Agent name');
1112
+ console.error(' --owner, -o Owner name');
1113
+ console.error(' --server-name Server label (optional)');
1114
+ console.error(' --notes Notes about this contact');
1115
+ console.error(' --tags Comma-separated tags');
1116
+ console.error(' --link Link to token ID you gave them');
1117
+ console.error(' --public-key Ed25519 public key (base64, or "fetch" to get from /status)');
1117
1118
  process.exit(1);
1118
1119
  }
1119
1120
 
@@ -1126,6 +1127,24 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1126
1127
  linkedTokenId: args.flags.link || null
1127
1128
  };
1128
1129
 
1130
+ // A2A-52: fetch or accept public key for identity verification
1131
+ const pubKeyFlag = args.flags['public-key'] || args.flags.public_key || args.flags.publicKey;
1132
+ if (pubKeyFlag === 'fetch' || pubKeyFlag === true) {
1133
+ try {
1134
+ const client = new A2AClient({});
1135
+ const statusResult = await client.status(url);
1136
+ if (statusResult.public_key) {
1137
+ options.public_key = statusResult.public_key;
1138
+ const { fingerprint: fpFunc } = require('../src/lib/crypto');
1139
+ console.log(` Fetched public key: ${fpFunc(statusResult.public_key)}`);
1140
+ }
1141
+ } catch (fetchErr) {
1142
+ console.error(` Warning: could not fetch public key from /status: ${fetchErr.message}`);
1143
+ }
1144
+ } else if (pubKeyFlag && typeof pubKeyFlag === 'string') {
1145
+ options.public_key = pubKeyFlag;
1146
+ }
1147
+
1129
1148
  try {
1130
1149
  const result = store.addContact(url, options);
1131
1150
  if (!result.success) {
@@ -1186,13 +1205,22 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1186
1205
  console.log(`šŸ” No linked token (you haven't given them access yet)`);
1187
1206
  }
1188
1207
 
1208
+ // A2A-52: show cryptographic identity verification status
1209
+ if (remote.public_key) {
1210
+ const { fingerprint: fpFunc } = require('../src/lib/crypto');
1211
+ console.log(`šŸ”‘ Identity: verified`);
1212
+ console.log(` Fingerprint: ${fpFunc(remote.public_key)}`);
1213
+ } else {
1214
+ console.log(`šŸ”‘ Identity: unverified (no public key pinned)`);
1215
+ }
1216
+
1189
1217
  if (remote.tags && remote.tags.length > 0) {
1190
1218
  console.log(`šŸ·ļø Tags: ${remote.tags.join(', ')}`);
1191
1219
  }
1192
1220
  if (remote.notes) {
1193
1221
  console.log(`šŸ“ Notes: ${remote.notes}`);
1194
1222
  }
1195
-
1223
+
1196
1224
  console.log(`\nšŸ“… Added: ${new Date(remote.added_at).toLocaleDateString()}`);
1197
1225
  if (remote.last_seen) {
1198
1226
  console.log(`šŸ“ Last seen: ${formatTimeAgo(new Date(remote.last_seen))}`);
@@ -1300,6 +1328,23 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1300
1328
  console.log(`🟢 ${remote.name} is online`);
1301
1329
  console.log(` Agent: ${result.name}`);
1302
1330
  console.log(` Version: ${result.version}`);
1331
+
1332
+ // A2A-52: also fetch /status to refresh public key
1333
+ try {
1334
+ const statusResult = await client.status(url);
1335
+ if (statusResult.public_key) {
1336
+ const { fingerprint: fpFunc } = require('../src/lib/crypto');
1337
+ if (remote.public_key && remote.public_key !== statusResult.public_key) {
1338
+ console.log(` āš ļø Public key changed!`);
1339
+ console.log(` Old: ${fpFunc(remote.public_key)}`);
1340
+ console.log(` New: ${fpFunc(statusResult.public_key)}`);
1341
+ }
1342
+ store.updateContact(name, { public_key: statusResult.public_key });
1343
+ console.log(` šŸ”‘ Fingerprint: ${fpFunc(statusResult.public_key)}`);
1344
+ }
1345
+ } catch (_) {
1346
+ // /status fetch is best-effort during ping
1347
+ }
1303
1348
  } catch (err) {
1304
1349
  store.updateContactStatus(name, 'offline', err.message);
1305
1350
  console.log(`šŸ”“ ${remote.name} is offline`);
@@ -1535,6 +1580,8 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1535
1580
  // Best effort
1536
1581
  }
1537
1582
 
1583
+ // A2A-52: load keypair for request signing in multi-turn calls
1584
+ const _multiKeypair = config.getKeypair();
1538
1585
  const driver = new ConversationDriver({
1539
1586
  runtime,
1540
1587
  agentContext,
@@ -1546,6 +1593,8 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1546
1593
  maxTurns,
1547
1594
  configTurnTimeoutMs,
1548
1595
  ownerContext,
1596
+ privateKey: _multiKeypair ? _multiKeypair.privateKey : null,
1597
+ publicKey: _multiKeypair ? _multiKeypair.publicKey : null,
1549
1598
  onTurn: (info) => {
1550
1599
  const preview = info.messagePreview.length >= 80
1551
1600
  ? info.messagePreview + '...'
@@ -1586,8 +1635,12 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1586
1635
  }
1587
1636
 
1588
1637
  // Single-shot call (existing behavior)
1638
+ // A2A-52: load keypair for request signing
1639
+ const _callKeypair = config.getKeypair();
1589
1640
  const client = new A2AClient({
1590
- caller: { name: callerName }
1641
+ caller: { name: callerName },
1642
+ privateKey: _callKeypair ? _callKeypair.privateKey : null,
1643
+ publicKey: _callKeypair ? _callKeypair.publicKey : null
1591
1644
  });
1592
1645
 
1593
1646
  try {
@@ -2292,6 +2345,21 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
2292
2345
  } catch (_) {}
2293
2346
  }
2294
2347
 
2348
+ // A2A-52: Generate Ed25519 keypair for cryptographic identity (skip if already exists)
2349
+ const existingKeypair = config.getKeypair();
2350
+ if (!existingKeypair) {
2351
+ const { generateKeypair, fingerprint: fpFunc } = require('../src/lib/crypto');
2352
+ const keypair = generateKeypair();
2353
+ config.setKeypair(keypair.privateKey, keypair.publicKey);
2354
+ const fp = fpFunc(keypair.publicKey);
2355
+ console.log(`\n šŸ”‘ Ed25519 identity generated`);
2356
+ console.log(` Fingerprint: ${fp}`);
2357
+ } else {
2358
+ const { fingerprint: fpFunc } = require('../src/lib/crypto');
2359
+ console.log(`\n šŸ”‘ Ed25519 identity exists (not overwritten)`);
2360
+ console.log(` Fingerprint: ${fpFunc(existingKeypair.publicKey)}`);
2361
+ }
2362
+
2295
2363
  // Save server config and advance onboarding state to awaiting_disclosure.
2296
2364
  config.setAgent({ hostname: publicHost });
2297
2365
  config.setOnboarding({ step: 'awaiting_disclosure' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2acalling",
3
- "version": "0.6.64",
3
+ "version": "0.6.66",
4
4
  "description": "Agent-to-agent calling for OpenClaw - A2A agent communication",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -255,13 +255,15 @@ async function copyText(value) {
255
255
  }
256
256
 
257
257
  // A2A-47: Panel name → section title mapping for the content header
258
+ // A2A-50: Added 'settings' panel for relocated admin settings
258
259
  const panelTitles = {
259
260
  contacts: 'Contacts',
260
261
  calls: 'Calls',
261
262
  permissions: 'Permissions',
262
263
  invites: 'Invites',
263
264
  logs: 'Logs',
264
- health: 'Health'
265
+ health: 'Health',
266
+ settings: 'Settings'
265
267
  };
266
268
 
267
269
  // A2A-47: Show a specific panel and update sidebar + header state.
@@ -313,9 +315,8 @@ function bindTabs() {
313
315
  // Deep-link support: activate the panel matching the URL hash
314
316
  const activateFromHash = () => {
315
317
  let hash = window.location.hash.slice(1);
316
- // A2A-41: backward-compat alias — old bookmarks/links using #settings
317
- // still work after rename to #permissions
318
- if (hash === 'settings') hash = 'permissions';
318
+ // A2A-50: #settings now points to the real Settings panel (moved from
319
+ // being a backward-compat alias for #permissions in A2A-41).
319
320
  if (hash) {
320
321
  showPanel(hash);
321
322
  }
@@ -1495,8 +1496,10 @@ function bindSidebarDrag() {
1495
1496
  });
1496
1497
  }
1497
1498
 
1498
- // A2A-48: Drop handler for active topic/goal zones. Extracted from
1499
- // bindSidebarDrag() to be called once in bindPermissionsActions().
1499
+ // A2A-50: Drop handler for active topic/goal zones. Routes items to the
1500
+ // CORRECT zone based on data.type (not the zone they were dropped on).
1501
+ // This fixes the bug where dragging a topic onto the goals zone would
1502
+ // incorrectly add it as a goal and vice versa.
1500
1503
  function handleZoneDrop(zone, e) {
1501
1504
  e.preventDefault();
1502
1505
  zone.classList.remove('drag-over');
@@ -1504,49 +1507,70 @@ function handleZoneDrop(zone, e) {
1504
1507
  try { data = JSON.parse(e.dataTransfer.getData('application/json')); } catch { return; }
1505
1508
  if (!data.name) return;
1506
1509
 
1507
- const isTopicZone = zone.id === 'active-topics-zone';
1508
- const accentClass = isTopicZone ? 'active-item-card--teal' : 'active-item-card--yellow';
1509
- const typeLabel = isTopicZone ? 'Topic' : 'Goal';
1510
- const removeAttr = isTopicZone ? 'data-remove-topic' : 'data-remove-goal';
1510
+ // A2A-50: Route to correct zone by data.type, NOT by which zone received
1511
+ // the drop. Falls back to zone.id routing if data.type is missing (defensive).
1512
+ const itemType = data.type || (zone.id === 'active-topics-zone' ? 'topic' : 'goal');
1513
+ const targetZoneId = itemType === 'topic' ? 'active-topics-zone' : 'active-goals-zone';
1514
+ const targetZone = document.getElementById(targetZoneId);
1515
+ if (!targetZone) return;
1511
1516
 
1512
- // Check if already in zone
1513
- const existing = zone.querySelectorAll('.active-item-card');
1517
+ const accentClass = itemType === 'topic' ? 'active-item-card--teal' : 'active-item-card--yellow';
1518
+ const typeLabel = itemType === 'topic' ? 'Topic' : 'Goal';
1519
+ const removeAttr = itemType === 'topic' ? 'data-remove-topic' : 'data-remove-goal';
1520
+
1521
+ // Check if already in target zone
1522
+ const existing = targetZone.querySelectorAll('.active-item-card');
1514
1523
  for (const card of existing) {
1515
1524
  if (card.dataset.topic === data.name) return; // already active
1516
1525
  }
1517
1526
 
1518
- // Insert before the placeholder
1519
- const placeholder = zone.querySelector('.drop-placeholder');
1527
+ // A2A-50: Shared helper builds the card HTML to avoid duplication with
1528
+ // the create-item-submit handler. Both paths now use buildItemCard().
1529
+ const card = buildItemCard(data.name, data.description || '', accentClass, typeLabel, removeAttr);
1530
+ const placeholder = targetZone.querySelector('.drop-placeholder');
1531
+ targetZone.insertBefore(card, placeholder);
1532
+ autoSaveTier();
1533
+
1534
+ // A2A-48: Re-fetch tier from state instead of using captured reference,
1535
+ // since autoSaveTier() may refresh state.settings asynchronously.
1536
+ setTimeout(() => {
1537
+ const freshTier = (state.settings?.tiers || []).find(t => t.id === state.activeTierId);
1538
+ if (freshTier) {
1539
+ renderSidebarLists(freshTier);
1540
+ // A2A-51: Re-bind drag listeners after innerHTML replacement in renderSidebarLists().
1541
+ // Without this, sidebar items lose dragstart/dragend handlers after the first drop.
1542
+ bindSidebarDrag();
1543
+ }
1544
+ }, 300);
1545
+ }
1546
+
1547
+ // A2A-50: Shared helper to build an active-item card DOM element.
1548
+ // Used by both handleZoneDrop() and the create-item-submit handler
1549
+ // to avoid duplicating card HTML in two places.
1550
+ function buildItemCard(name, description, accentClass, typeLabel, removeAttr) {
1520
1551
  const card = document.createElement('div');
1521
1552
  card.className = `active-item-card ${accentClass}`;
1522
- card.dataset.topic = data.name;
1523
- card.dataset.description = data.description || '';
1553
+ card.dataset.topic = name;
1554
+ card.dataset.description = description;
1524
1555
  card.innerHTML = `
1525
1556
  <div>
1526
- <div class="item-name">${esc(data.name)}</div>
1557
+ <div class="item-name">${esc(name)}</div>
1527
1558
  <div class="item-type-label">${typeLabel}</div>
1528
1559
  </div>
1529
- <button class="item-close-btn" ${removeAttr}="${esc(data.name)}">
1560
+ <button class="item-close-btn" ${removeAttr}="${esc(name)}">
1530
1561
  <span class="material-symbols-outlined" style="font-size:16px;">close</span>
1531
1562
  </button>
1532
1563
  `;
1533
- zone.insertBefore(card, placeholder);
1534
- autoSaveTier();
1535
-
1536
- // A2A-48: Re-fetch tier from state instead of using captured reference,
1537
- // since autoSaveTier() may refresh state.settings asynchronously.
1538
- setTimeout(() => {
1539
- const freshTier = (state.settings?.tiers || []).find(t => t.id === state.activeTierId);
1540
- if (freshTier) renderSidebarLists(freshTier);
1541
- }, 300);
1564
+ return card;
1542
1565
  }
1543
1566
 
1544
- // A2A-48: Extracted from old fillTierSelects(). Populates only the
1545
- // #invite-tier (Invites tab) and #new-tier-copy-from (Settings details).
1546
- // Does NOT populate the removed #tier-select or #copy-from-tier.
1567
+ // A2A-50: Populates tier select dropdowns: #invite-tier (Invites tab),
1568
+ // #new-tier-copy-from (Settings tab), and #new-tier-dialog-copy-from
1569
+ // (new tier modal in Permissions tab).
1547
1570
  function populateInviteTierSelect() {
1548
1571
  const tiers = (state.settings?.tiers || []).slice().sort((a, b) => a.id.localeCompare(b.id));
1549
1572
  const newTierCopy = document.getElementById('new-tier-copy-from');
1573
+ const newTierDialogCopy = document.getElementById('new-tier-dialog-copy-from');
1550
1574
  const inviteTier = document.getElementById('invite-tier');
1551
1575
 
1552
1576
  const optionsHtml = tiers.map(tier => {
@@ -1556,6 +1580,8 @@ function populateInviteTierSelect() {
1556
1580
 
1557
1581
  if (inviteTier) inviteTier.innerHTML = optionsHtml;
1558
1582
  if (newTierCopy) newTierCopy.innerHTML = `<sl-option value="">None</sl-option>${optionsHtml}`;
1583
+ // A2A-50: Also populate the Copy From select inside the new-tier-dialog modal
1584
+ if (newTierDialogCopy) newTierDialogCopy.innerHTML = `<sl-option value="">None</sl-option>${optionsHtml}`;
1559
1585
 
1560
1586
  // A2A-48: Default invite tier to 'public'
1561
1587
  const defaultTier = tiers.find(t => t.id === 'public') ? 'public' : tiers[0]?.id;
@@ -1683,6 +1709,23 @@ function openCallerPreview() {
1683
1709
  dialog.show();
1684
1710
  }
1685
1711
 
1712
+ // A2A-50: Shows the delete confirmation dialog for a topic/goal card.
1713
+ // Uses a unique marker attribute on the card so the confirm handler can
1714
+ // find and remove it. This avoids storing DOM references in closure state.
1715
+ let _deleteIdCounter = 0;
1716
+ function showDeleteConfirm(itemName, itemType, cardElement) {
1717
+ const dialog = document.getElementById('delete-confirm-dialog');
1718
+ if (!dialog) return;
1719
+ const label = itemType === 'topic' ? 'Topic' : 'Goal';
1720
+ const msgEl = document.getElementById('delete-confirm-message');
1721
+ if (msgEl) msgEl.textContent = `Remove ${label} "${itemName}" from this tier?`;
1722
+ // Tag the card with a unique ID so the confirm handler can find it
1723
+ const deleteId = `del-${++_deleteIdCounter}`;
1724
+ cardElement.setAttribute('data-delete-id', deleteId);
1725
+ dialog.dataset.deleteCardId = deleteId;
1726
+ dialog.show();
1727
+ }
1728
+
1686
1729
  // A2A-48: Binds all event handlers for the permissions panel. Replaces old
1687
1730
  // bindSettingsActions() — removes handlers for deleted elements (tier-form,
1688
1731
  // tier-select, copy-tier-btn, show-drag-columns, preview-caller-btn) and
@@ -1701,38 +1744,46 @@ function bindPermissionsActions() {
1701
1744
  return;
1702
1745
  }
1703
1746
 
1704
- // A2A-48: Close button on active topic cards
1747
+ // A2A-50: Close button on active topic cards opens delete confirmation dialog
1705
1748
  const removeTopic = e.target.closest('[data-remove-topic]');
1706
1749
  if (removeTopic) {
1707
1750
  const card = removeTopic.closest('.active-item-card');
1708
- if (card) card.remove();
1709
- autoSaveTier();
1751
+ if (card) {
1752
+ const itemName = card.dataset.topic || '';
1753
+ showDeleteConfirm(itemName, 'topic', card);
1754
+ }
1710
1755
  return;
1711
1756
  }
1712
1757
 
1713
- // A2A-48: Close button on active goal cards
1758
+ // A2A-50: Close button on active goal cards opens delete confirmation dialog
1714
1759
  const removeGoal = e.target.closest('[data-remove-goal]');
1715
1760
  if (removeGoal) {
1716
1761
  const card = removeGoal.closest('.active-item-card');
1717
- if (card) card.remove();
1718
- autoSaveTier();
1762
+ if (card) {
1763
+ const itemName = card.dataset.topic || '';
1764
+ showDeleteConfirm(itemName, 'goal', card);
1765
+ }
1719
1766
  return;
1720
1767
  }
1721
1768
 
1722
- // A2A-48: "+ New Tier" button scrolls to the new-tier form inside Settings details
1769
+ // A2A-50: "+ New Tier" button opens glass-styled sl-dialog instead of
1770
+ // scrolling to inline form. Keeps focus trap and accessibility from Shoelace.
1723
1771
  const newTierBtn = e.target.closest('#perm-new-tier-btn');
1724
1772
  if (newTierBtn) {
1725
- const details = panel.querySelector('sl-details');
1726
- if (details) details.open = true;
1727
- setTimeout(() => {
1728
- const el = document.getElementById('new-tier-id');
1729
- if (el) { el.scrollIntoView({ behavior: 'smooth' }); el.focus(); }
1730
- }, 200);
1773
+ const dialog = document.getElementById('new-tier-dialog');
1774
+ if (dialog) {
1775
+ const idInput = document.getElementById('new-tier-dialog-id');
1776
+ const nameInput = document.getElementById('new-tier-dialog-name');
1777
+ if (idInput) idInput.value = '';
1778
+ if (nameInput) nameInput.value = '';
1779
+ dialog.show();
1780
+ }
1731
1781
  return;
1732
1782
  }
1733
1783
 
1734
- // A2A-48: Sidebar "Add Topic" / "Add Goal" buttons open create dialog
1735
- const addBtn = e.target.closest('.sidebar-add-btn[data-add-type]');
1784
+ // A2A-48: Sidebar "Add Topic" / "Add Goal" buttons open create dialog.
1785
+ // A2A-51: Also matches .col-header-add-btn for narrow viewports where sidebar is hidden.
1786
+ const addBtn = e.target.closest('[data-add-type]');
1736
1787
  if (addBtn) {
1737
1788
  const type = addBtn.dataset.addType;
1738
1789
  const dialog = document.getElementById('create-item-dialog');
@@ -1753,13 +1804,17 @@ function bindPermissionsActions() {
1753
1804
  // (#active-topics-zone, #active-goals-zone) persist across renders. Only
1754
1805
  // their innerHTML is replaced by renderActiveTopics/renderActiveGoals.
1755
1806
  // Binding in bindSidebarDrag() would cause listener accumulation.
1807
+ // A2A-51: Uses dragenter/dragleave counter to prevent flickering when
1808
+ // cursor moves over child elements (cards, placeholder) inside the zone.
1756
1809
  const topicZone = document.getElementById('active-topics-zone');
1757
1810
  const goalZone = document.getElementById('active-goals-zone');
1758
1811
  [topicZone, goalZone].forEach(zone => {
1759
1812
  if (!zone) return;
1760
- zone.addEventListener('dragover', (e) => { e.preventDefault(); zone.classList.add('drag-over'); });
1761
- zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
1762
- zone.addEventListener('drop', (e) => handleZoneDrop(zone, e));
1813
+ let dragCounter = 0;
1814
+ zone.addEventListener('dragenter', (e) => { e.preventDefault(); dragCounter++; zone.classList.add('drag-over'); });
1815
+ zone.addEventListener('dragover', (e) => { e.preventDefault(); });
1816
+ zone.addEventListener('dragleave', () => { dragCounter--; if (dragCounter === 0) zone.classList.remove('drag-over'); });
1817
+ zone.addEventListener('drop', (e) => { dragCounter = 0; handleZoneDrop(zone, e); });
1763
1818
  });
1764
1819
 
1765
1820
  // A2A-48: Tool toggle change — auto-save and update card styling
@@ -1775,7 +1830,8 @@ function bindPermissionsActions() {
1775
1830
  }
1776
1831
  });
1777
1832
 
1778
- // A2A-48: Create Item dialog — submit handler
1833
+ // A2A-50: Create Item dialog — submit handler. Uses shared buildItemCard()
1834
+ // helper to avoid duplicating card HTML with handleZoneDrop().
1779
1835
  document.getElementById('create-item-submit')?.addEventListener('click', () => {
1780
1836
  const dialog = document.getElementById('create-item-dialog');
1781
1837
  const titleInput = document.getElementById('create-item-title');
@@ -1787,7 +1843,6 @@ function bindPermissionsActions() {
1787
1843
  const desc = descInput?.value?.trim() || '';
1788
1844
  const type = dialog.dataset.createType || 'topic';
1789
1845
 
1790
- // A2A-48: Add item to the appropriate active zone
1791
1846
  const zoneId = type === 'topic' ? 'active-topics-zone' : 'active-goals-zone';
1792
1847
  const zone = document.getElementById(zoneId);
1793
1848
  if (!zone) return;
@@ -1796,20 +1851,8 @@ function bindPermissionsActions() {
1796
1851
  const typeLabel = type === 'topic' ? 'Topic' : 'Goal';
1797
1852
  const removeAttr = type === 'topic' ? 'data-remove-topic' : 'data-remove-goal';
1798
1853
 
1854
+ const card = buildItemCard(title, desc, accentClass, typeLabel, removeAttr);
1799
1855
  const placeholder = zone.querySelector('.drop-placeholder');
1800
- const card = document.createElement('div');
1801
- card.className = `active-item-card ${accentClass}`;
1802
- card.dataset.topic = title;
1803
- card.dataset.description = desc;
1804
- card.innerHTML = `
1805
- <div>
1806
- <div class="item-name">${esc(title)}</div>
1807
- <div class="item-type-label">${typeLabel}</div>
1808
- </div>
1809
- <button class="item-close-btn" ${removeAttr}="${esc(title)}">
1810
- <span class="material-symbols-outlined" style="font-size:16px;">close</span>
1811
- </button>
1812
- `;
1813
1856
  if (placeholder) zone.insertBefore(card, placeholder);
1814
1857
  else zone.appendChild(card);
1815
1858
 
@@ -1822,41 +1865,69 @@ function bindPermissionsActions() {
1822
1865
  document.getElementById('create-item-dialog')?.hide();
1823
1866
  });
1824
1867
 
1825
- // Defaults form — unchanged from A2A-41
1826
- document.getElementById('defaults-form')?.addEventListener('submit', async (e) => {
1827
- e.preventDefault();
1828
- await request('/settings/defaults', {
1829
- method: 'PUT',
1830
- body: JSON.stringify({
1831
- expiration: document.getElementById('defaults-expiration').value,
1832
- maxCalls: Number.parseInt(document.getElementById('defaults-max-calls').value, 10) || 100
1833
- })
1834
- });
1835
- showNotice('Saved defaults');
1836
- await loadSettings();
1868
+ // A2A-50: New Tier dialog — submit handler (replaces inline form handler).
1869
+ // Creates tier via POST and switches to it.
1870
+ document.getElementById('new-tier-dialog-submit')?.addEventListener('click', async () => {
1871
+ const tierId = document.getElementById('new-tier-dialog-id')?.value?.trim();
1872
+ const name = document.getElementById('new-tier-dialog-name')?.value?.trim();
1873
+ const copyFrom = document.getElementById('new-tier-dialog-copy-from')?.value;
1874
+ if (!tierId) {
1875
+ document.getElementById('new-tier-dialog-id')?.focus();
1876
+ return;
1877
+ }
1878
+ try {
1879
+ await request('/settings/tiers', {
1880
+ method: 'POST',
1881
+ body: JSON.stringify({
1882
+ id: tierId,
1883
+ name: name || tierId,
1884
+ copy_from: copyFrom || undefined
1885
+ })
1886
+ });
1887
+ showNotice(`Created tier "${tierId}"`);
1888
+ document.getElementById('new-tier-dialog')?.hide();
1889
+ await loadSettings();
1890
+ state.activeTierId = tierId;
1891
+ renderPermissions();
1892
+ } catch (err) {
1893
+ showNotice(err.message);
1894
+ }
1837
1895
  });
1838
1896
 
1839
- // A2A-48: New Tier form — uses state.activeTierId instead of removed #tier-select
1840
- document.getElementById('new-tier-form')?.addEventListener('submit', async (e) => {
1841
- e.preventDefault();
1842
- const tierId = document.getElementById('new-tier-id').value.trim();
1843
- const name = document.getElementById('new-tier-name').value.trim();
1844
- const copyFrom = document.getElementById('new-tier-copy-from').value;
1845
- if (!tierId) return;
1846
- await request('/settings/tiers', {
1847
- method: 'POST',
1848
- body: JSON.stringify({
1849
- id: tierId,
1850
- name: name || tierId,
1851
- copy_from: copyFrom || undefined
1852
- })
1853
- });
1854
- showNotice(`Created tier "${tierId}"`);
1855
- document.getElementById('new-tier-form').reset();
1856
- await loadSettings();
1857
- // A2A-48: Switch to the newly created tier (replaces old tier-select.value = tierId)
1858
- state.activeTierId = tierId;
1859
- renderPermissions();
1897
+ // A2A-50: New Tier dialog — cancel handler
1898
+ document.getElementById('new-tier-dialog-cancel')?.addEventListener('click', () => {
1899
+ document.getElementById('new-tier-dialog')?.hide();
1900
+ });
1901
+
1902
+ // A2A-50: Delete confirm dialog — confirm handler. Removes the card that
1903
+ // was stored in dataset and triggers auto-save.
1904
+ document.getElementById('delete-confirm-yes')?.addEventListener('click', () => {
1905
+ const dialog = document.getElementById('delete-confirm-dialog');
1906
+ if (!dialog) return;
1907
+ const cardId = dialog.dataset.deleteCardId;
1908
+ if (cardId) {
1909
+ const card = document.querySelector(`.active-item-card[data-delete-id="${cardId}"]`);
1910
+ if (card) {
1911
+ card.removeAttribute('data-delete-id');
1912
+ card.remove();
1913
+ }
1914
+ }
1915
+ dialog.hide();
1916
+ autoSaveTier();
1917
+ });
1918
+
1919
+ // A2A-50: Delete confirm dialog — cancel handler
1920
+ document.getElementById('delete-confirm-no')?.addEventListener('click', () => {
1921
+ const dialog = document.getElementById('delete-confirm-dialog');
1922
+ if (dialog) {
1923
+ // Clean up the marker attribute from the card
1924
+ const cardId = dialog.dataset.deleteCardId;
1925
+ if (cardId) {
1926
+ const card = document.querySelector(`.active-item-card[data-delete-id="${cardId}"]`);
1927
+ if (card) card.removeAttribute('data-delete-id');
1928
+ }
1929
+ dialog.hide();
1930
+ }
1860
1931
  });
1861
1932
 
1862
1933
  // Preview dialog close — unchanged
@@ -2243,6 +2314,8 @@ const tabLoaders = {
2243
2314
  permissions: loadSettings,
2244
2315
  invites: loadInvites,
2245
2316
  health: loadHealth,
2317
+ // A2A-50: Settings tab loads dashboard status, auto-update, and callbook data
2318
+ settings: () => { loadDashboardStatus(); loadAutoUpdateStatus(); loadCallbookDevices(); },
2246
2319
  };
2247
2320
 
2248
2321
  function startPolling() {
@@ -2361,6 +2434,55 @@ function renderHealthHistory(history) {
2361
2434
  }).join('');
2362
2435
  }
2363
2436
 
2437
+ // A2A-50: Binds handlers for the Settings panel (defaults form, new-tier form).
2438
+ // These were previously inside bindPermissionsActions() but are now in the
2439
+ // separate #panel-settings panel.
2440
+ function bindSettingsActions() {
2441
+ // Defaults form
2442
+ document.getElementById('defaults-form')?.addEventListener('submit', async (e) => {
2443
+ e.preventDefault();
2444
+ try {
2445
+ await request('/settings/defaults', {
2446
+ method: 'PUT',
2447
+ body: JSON.stringify({
2448
+ expiration: document.getElementById('defaults-expiration').value,
2449
+ maxCalls: Number.parseInt(document.getElementById('defaults-max-calls').value, 10) || 100
2450
+ })
2451
+ });
2452
+ showNotice('Saved defaults');
2453
+ await loadSettings();
2454
+ } catch (err) {
2455
+ showNotice(err.message);
2456
+ }
2457
+ });
2458
+
2459
+ // New Tier form (inline form in Settings tab, kept for non-modal access)
2460
+ document.getElementById('new-tier-form')?.addEventListener('submit', async (e) => {
2461
+ e.preventDefault();
2462
+ const tierId = document.getElementById('new-tier-id')?.value?.trim();
2463
+ const name = document.getElementById('new-tier-name')?.value?.trim();
2464
+ const copyFrom = document.getElementById('new-tier-copy-from')?.value;
2465
+ if (!tierId) return;
2466
+ try {
2467
+ await request('/settings/tiers', {
2468
+ method: 'POST',
2469
+ body: JSON.stringify({
2470
+ id: tierId,
2471
+ name: name || tierId,
2472
+ copy_from: copyFrom || undefined
2473
+ })
2474
+ });
2475
+ showNotice(`Created tier "${tierId}"`);
2476
+ document.getElementById('new-tier-form')?.reset();
2477
+ await loadSettings();
2478
+ state.activeTierId = tierId;
2479
+ renderPermissions();
2480
+ } catch (err) {
2481
+ showNotice(err.message);
2482
+ }
2483
+ });
2484
+ }
2485
+
2364
2486
  async function bootstrap() {
2365
2487
  bindTabs();
2366
2488
  bindContactsActions();
@@ -2368,6 +2490,8 @@ async function bootstrap() {
2368
2490
  // bindItemListDelegation(). All tier/tool/topic/goal handlers are now
2369
2491
  // inside bindPermissionsActions() using event delegation on #panel-permissions.
2370
2492
  bindPermissionsActions();
2493
+ // A2A-50: Settings panel handlers (defaults, new-tier inline form)
2494
+ bindSettingsActions();
2371
2495
  bindCallbookActions();
2372
2496
  bindAutoUpdateActions();
2373
2497
  bindInviteActions();