a2acalling 0.6.63 → 0.6.65

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.
@@ -15,7 +15,12 @@ const state = {
15
15
  realtime: {
16
16
  connected: false,
17
17
  lastEventId: null
18
- }
18
+ },
19
+ // A2A-47: Track active panel for sidebar navigation (replaces sl-tab-group)
20
+ activeTab: 'contacts',
21
+ // A2A-48: Track currently selected tier for the permissions panel.
22
+ // Replaces the old #tier-select dropdown value.
23
+ activeTierId: 'public'
19
24
  };
20
25
 
21
26
  let dashboardEventSource = null;
@@ -249,37 +254,78 @@ async function copyText(value) {
249
254
  }
250
255
  }
251
256
 
252
- function bindTabs() {
253
- const tabGroup = document.getElementById('main-tabs');
254
- if (!tabGroup) return;
257
+ // A2A-47: Panel name → section title mapping for the content header
258
+ // A2A-50: Added 'settings' panel for relocated admin settings
259
+ const panelTitles = {
260
+ contacts: 'Contacts',
261
+ calls: 'Calls',
262
+ permissions: 'Permissions',
263
+ invites: 'Invites',
264
+ logs: 'Logs',
265
+ health: 'Health',
266
+ settings: 'Settings'
267
+ };
255
268
 
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);
269
+ // A2A-47: Show a specific panel and update sidebar + header state.
270
+ // Replaces the old sl-tab-group navigation.
271
+ function showPanel(name) {
272
+ const validPanels = Object.keys(panelTitles);
273
+ if (!validPanels.includes(name)) name = 'contacts';
274
+
275
+ // Hide all panels, show the target
276
+ document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
277
+ const target = document.getElementById('panel-' + name);
278
+ if (target) target.classList.add('active');
279
+
280
+ // Update sidebar active state
281
+ document.querySelectorAll('.nav-item').forEach(item => {
282
+ if (item.dataset.panel === name) {
283
+ item.classList.add('active');
284
+ } else {
285
+ item.classList.remove('active');
286
+ }
260
287
  });
261
288
 
262
- // Deep-link support: activate the tab matching the URL hash
289
+ // Update header title
290
+ const titleEl = document.getElementById('section-title');
291
+ if (titleEl) titleEl.textContent = panelTitles[name] || name;
292
+
293
+ // Update state and hash
294
+ state.activeTab = name;
295
+ try {
296
+ if (window.location.hash.slice(1) !== name) {
297
+ window.location.hash = name;
298
+ }
299
+ } catch (err) {}
300
+
301
+ // Trigger data loading for the active tab
302
+ if (typeof onTabSwitch === 'function') onTabSwitch(name);
303
+ }
304
+
305
+ function bindTabs() {
306
+ // A2A-47: Sidebar nav click handler
307
+ document.querySelectorAll('.nav-item').forEach(item => {
308
+ item.addEventListener('click', (e) => {
309
+ e.preventDefault();
310
+ const panel = item.dataset.panel;
311
+ if (panel) showPanel(panel);
312
+ });
313
+ });
314
+
315
+ // Deep-link support: activate the panel matching the URL hash
263
316
  const activateFromHash = () => {
264
317
  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';
318
+ // A2A-50: #settings now points to the real Settings panel (moved from
319
+ // being a backward-compat alias for #permissions in A2A-41).
268
320
  if (hash) {
269
- // Use try/catch in case the tab group isn't fully ready
270
- try { tabGroup.show(hash); } catch (err) {}
321
+ showPanel(hash);
271
322
  }
272
323
  };
273
324
 
274
325
  window.addEventListener('hashchange', activateFromHash);
275
326
 
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
- }
327
+ // On initial load, activate from hash
328
+ activateFromHash();
283
329
  }
284
330
 
285
331
  function norm(value) {
@@ -393,7 +439,7 @@ function renderContacts() {
393
439
  </td>
394
440
  ${locationCell}
395
441
  ${ownerCell}
396
- <td>${esc(c?.status || '-')}</td>
442
+ <td><span class="contact-status" data-status="${esc(c?.status || 'unknown')}">${esc(c?.status || '-')}</span></td>
397
443
  <td>${esc(String(calls))}</td>
398
444
  <td>${esc(lastCallAt)}</td>
399
445
  ${summaryCell}
@@ -562,8 +608,8 @@ function bindContactsActions() {
562
608
  }
563
609
  });
564
610
 
565
- // Event delegation on the contacts tab panel
566
- const panel = document.querySelector('sl-tab-panel[name="contacts"]');
611
+ // A2A-47: Event delegation on the contacts panel (was sl-tab-panel, now div#panel-contacts)
612
+ const panel = document.querySelector('#panel-contacts');
567
613
  panel?.addEventListener('click', async (e) => {
568
614
  const pinBtn = e.target.closest('[data-pin-contact]');
569
615
  if (pinBtn) {
@@ -1062,7 +1108,7 @@ function renderLogs() {
1062
1108
  const trace = row.trace_id || '';
1063
1109
  tr.innerHTML = `
1064
1110
  <td>${esc(fmtDate(row.timestamp))}</td>
1065
- <td>${esc(row.level || '-')}</td>
1111
+ <td><span class="log-level" data-level="${esc(row.level || '')}">${esc(row.level || '-')}</span></td>
1066
1112
  <td>${esc(row.component || '-')}</td>
1067
1113
  <td>${esc(row.event || '-')}</td>
1068
1114
  <td title="${esc(row.message || '')}">${esc(String(row.message || '').slice(0, 120) || '-')}</td>
@@ -1117,53 +1163,113 @@ const TOOL_DESCRIPTIONS = {
1117
1163
  // A2A-41: standard tier order for inheritance. Custom tiers are not in this list.
1118
1164
  const TIER_ORDER = ['public', 'friends', 'family'];
1119
1165
 
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');
1166
+ // A2A-48: Material icon mapping for tier cards. Standard tiers get recognizable
1167
+ // icons; custom tiers get a wrench icon. Used by renderTierCards().
1168
+ const TIER_ICONS = { public: 'public', friends: 'group', family: 'family_restroom' };
1169
+
1170
+ // A2A-48: Color mapping for tool icons in toggle cards. Gives each tool a
1171
+ // distinct color matching the concept mock's visual differentiation.
1172
+ const TOOL_ICON_MAP = {
1173
+ 'Bash': { icon: 'terminal', bg: 'rgba(99,102,241,0.2)', color: '#818CF8', border: 'rgba(99,102,241,0.2)' },
1174
+ 'Bash(readonly)': { icon: 'terminal', bg: 'rgba(99,102,241,0.15)', color: '#A5B4FC', border: 'rgba(99,102,241,0.15)' },
1175
+ 'Read': { icon: 'visibility', bg: 'rgba(59,130,246,0.2)', color: '#60A5FA', border: 'rgba(59,130,246,0.2)' },
1176
+ 'Grep': { icon: 'search', bg: 'rgba(139,92,246,0.2)', color: '#A78BFA', border: 'rgba(139,92,246,0.2)' },
1177
+ 'Glob': { icon: 'folder_open', bg: 'rgba(16,185,129,0.2)', color: '#34D399', border: 'rgba(16,185,129,0.2)' },
1178
+ 'WebSearch': { icon: 'public', bg: 'rgba(245,158,11,0.2)', color: '#FBBF24', border: 'rgba(245,158,11,0.2)' },
1179
+ 'WebFetch': { icon: 'language', bg: 'rgba(236,72,153,0.2)', color: '#F472B6', border: 'rgba(236,72,153,0.2)' }
1180
+ };
1181
+
1182
+ // A2A-48: Renders tier cards grid. Active card gets .active class with glow.
1183
+ function renderTierCards() {
1184
+ const container = document.getElementById('tier-cards');
1185
+ if (!container) return;
1186
+ const tiers = (state.settings?.tiers || []).slice().sort((a, b) => {
1187
+ const aIdx = TIER_ORDER.indexOf(a.id);
1188
+ const bIdx = TIER_ORDER.indexOf(b.id);
1189
+ if (aIdx >= 0 && bIdx >= 0) return aIdx - bIdx;
1190
+ if (aIdx >= 0) return -1;
1191
+ if (bIdx >= 0) return 1;
1192
+ return a.id.localeCompare(b.id);
1193
+ });
1194
+
1195
+ container.innerHTML = tiers.map(tier => {
1196
+ const isActive = tier.id === state.activeTierId;
1197
+ const icon = TIER_ICONS[tier.id] || 'build';
1198
+ const iconColor = isActive ? '#60A5FA' : '#6B7280';
1199
+ return `
1200
+ <div class="tier-card${isActive ? ' active' : ''}" data-tier-id="${esc(tier.id)}">
1201
+ <span class="material-symbols-outlined tier-card-icon" style="color:${iconColor};">${icon}</span>
1202
+ <span class="tier-card-name">${esc(tier.name || tier.id)}</span>
1203
+ ${isActive ? '<div class="status-dot status-dot--green"></div>' : ''}
1204
+ </div>
1205
+ `;
1206
+ }).join('');
1207
+ }
1208
+
1209
+ // A2A-48: Renders tool toggle cards (replaces checkboxes). Each tool is a
1210
+ // glass-panel card with icon, name, description, and a toggle switch.
1211
+ // Toggle change triggers autoSaveTier() for immediate persistence.
1212
+ function renderToolToggles(allowedTools) {
1213
+ const container = document.getElementById('tool-toggles');
1214
+ if (!container) return;
1124
1215
  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>`;
1216
+ const checked = (allowedTools || []).includes(tool);
1217
+ const iconInfo = TOOL_ICON_MAP[tool] || { icon: 'extension', bg: 'rgba(100,116,139,0.2)', color: '#94A3B8', border: 'rgba(100,116,139,0.2)' };
1218
+ return `
1219
+ <div class="tool-toggle-card${checked ? ' enabled' : ''}">
1220
+ <div class="tool-toggle-info">
1221
+ <div class="tool-icon" style="background:${iconInfo.bg};color:${iconInfo.color};border:1px solid ${iconInfo.border};">
1222
+ <span class="material-symbols-outlined">${iconInfo.icon}</span>
1223
+ </div>
1224
+ <div>
1225
+ <h3>${esc(tool)}</h3>
1226
+ <p>${esc(desc)}</p>
1227
+ </div>
1228
+ </div>
1229
+ <label class="toggle-switch">
1230
+ <input type="checkbox" data-tool="${esc(tool)}" ${checked ? 'checked' : ''}>
1231
+ <span class="slider"></span>
1232
+ </label>
1233
+ </div>
1234
+ `;
1127
1235
  }).join('');
1128
1236
  }
1129
1237
 
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');
1238
+ // A2A-48: Renders active topics in the drop zone as teal-accented cards.
1239
+ // Each card has a close button for removal. Updates #topic-count badge.
1240
+ function renderActiveTopics(tier) {
1241
+ const container = document.getElementById('active-topics-zone');
1242
+ if (!container) return;
1135
1243
  const manifestTopics = tier.manifest?.topics || [];
1136
1244
  const flatTopics = tier.topics || [];
1137
1245
 
1138
- // A2A-41: prefer manifest data (has descriptions), fall back to flat array
1139
1246
  const allTopics = manifestTopics.length > 0
1140
1247
  ? manifestTopics.map(t => ({ label: t.topic, desc: t.description || '' }))
1141
1248
  : flatTopics.map(t => ({ label: t, desc: '' }));
1142
1249
 
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>
1250
+ const cardsHtml = allTopics.map(t => `
1251
+ <div class="active-item-card active-item-card--teal" data-topic="${esc(t.label)}" data-description="${esc(t.desc)}">
1252
+ <div>
1253
+ <div class="item-name">${esc(t.label)}</div>
1254
+ <div class="item-type-label">Topic</div>
1156
1255
  </div>
1256
+ <button class="item-close-btn" data-remove-topic="${esc(t.label)}">
1257
+ <span class="material-symbols-outlined" style="font-size:16px;">close</span>
1258
+ </button>
1157
1259
  </div>
1158
1260
  `).join('');
1159
1261
 
1160
- container.innerHTML = rowsHtml + `<button class="add-item-btn" data-type="topic">+ Add topic</button>`;
1262
+ container.innerHTML = cardsHtml + '<div class="drop-placeholder"><span>+ Drop Topic</span></div>';
1263
+
1264
+ const badge = document.getElementById('topic-count');
1265
+ if (badge) badge.textContent = `${allTopics.length} Active`;
1161
1266
  }
1162
1267
 
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');
1268
+ // A2A-48: Renders active goals in the drop zone as yellow-accented cards.
1269
+ // Same pattern as topics but with yellow color variant.
1270
+ function renderActiveGoals(tier) {
1271
+ const container = document.getElementById('active-goals-zone');
1272
+ if (!container) return;
1167
1273
  const manifestGoals = tier.manifest?.objectives || [];
1168
1274
  const flatGoals = tier.goals || [];
1169
1275
 
@@ -1171,302 +1277,310 @@ function renderGoalList(tier) {
1171
1277
  ? manifestGoals.map(g => ({ label: g.objective || g.topic, desc: g.description || '' }))
1172
1278
  : flatGoals.map(g => ({ label: g, desc: '' }));
1173
1279
 
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>
1280
+ const cardsHtml = allGoals.map(g => `
1281
+ <div class="active-item-card active-item-card--yellow" data-topic="${esc(g.label)}" data-description="${esc(g.desc)}">
1282
+ <div>
1283
+ <div class="item-name">${esc(g.label)}</div>
1284
+ <div class="item-type-label">Goal</div>
1187
1285
  </div>
1286
+ <button class="item-close-btn" data-remove-goal="${esc(g.label)}">
1287
+ <span class="material-symbols-outlined" style="font-size:16px;">close</span>
1288
+ </button>
1188
1289
  </div>
1189
1290
  `).join('');
1190
1291
 
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
- });
1292
+ container.innerHTML = cardsHtml + '<div class="drop-placeholder"><span>+ Drop Goal</span></div>';
1280
1293
 
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
- });
1294
+ const badge = document.getElementById('goal-count');
1295
+ if (badge) badge.textContent = `${allGoals.length} Active`;
1291
1296
  }
1292
1297
 
1293
- function fillTierSelects() {
1294
- const tiers = (state.settings?.tiers || []).slice().sort((a, b) => a.id.localeCompare(b.id));
1295
- const tierSelect = document.getElementById('tier-select');
1296
- const copyFrom = document.getElementById('copy-from-tier');
1297
- const newTierCopy = document.getElementById('new-tier-copy-from');
1298
- const inviteTier = document.getElementById('invite-tier');
1299
-
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('');
1304
-
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);
1317
- }
1318
- }
1319
-
1320
- function renderTierEditor(tierId) {
1321
- const tier = (state.settings?.tiers || []).find(t => t.id === tierId);
1298
+ // A2A-48: Orchestrator that renders the entire permissions panel.
1299
+ // Uses state.activeTierId instead of reading a dropdown value.
1300
+ function renderPermissions() {
1301
+ const tier = (state.settings?.tiers || []).find(t => t.id === state.activeTierId);
1322
1302
  if (!tier) return;
1323
-
1324
- document.getElementById('tier-name').value = tier.name || tier.id;
1325
- document.getElementById('tier-description').value = tier.description || '';
1326
- renderToolCheckboxes(tier.allowed_tools);
1327
- renderTopicList(tier);
1328
- renderGoalList(tier);
1303
+ renderTierCards();
1304
+ renderActiveTopics(tier);
1305
+ renderActiveGoals(tier);
1306
+ renderToolToggles(tier.allowed_tools);
1329
1307
  renderTierWarnings(tier);
1330
- renderTierColumns();
1308
+ renderSidebarPreview(state.activeTierId);
1309
+ renderSidebarLists(tier);
1310
+ bindSidebarDrag();
1331
1311
  }
1332
1312
 
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');
1313
+ // A2A-48: Renders the inline "Preview as Caller" card in the right sidebar.
1314
+ // Reuses getPreviewData() to show merged topics, goals, and tool count.
1315
+ function renderSidebarPreview(tierId) {
1316
+ const container = document.getElementById('perm-preview');
1339
1317
  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
- });
1318
+ const data = getPreviewData(tierId);
1319
+ const tierName = (state.settings?.tiers || []).find(t => t.id === tierId)?.name || tierId;
1320
+ const topicNames = data.topics.map(t => t.topic).filter(Boolean);
1321
+ const goalNames = data.objectives.map(g => g.objective || g.topic).filter(Boolean);
1322
+ const toolCount = data.tools.size;
1323
+
1324
+ const topicText = topicNames.length > 0 ? `<strong style="color:#2DD4BF;">${esc(topicNames.join(', '))}</strong>` : '<em>no topics</em>';
1325
+ const goalText = goalNames.length > 0 ? `<strong style="color:#FBBF24;">${esc(goalNames.join(', '))}</strong>` : '<em>no goals</em>';
1326
+
1327
+ container.innerHTML = `
1328
+ <div class="preview-card-inner">
1329
+ <div class="sidebar-list-header">Preview as Caller</div>
1330
+ <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;">
1331
+ <span class="material-symbols-outlined" style="color:#60A5FA;">smart_toy</span>
1332
+ <div>
1333
+ <div style="font-weight:600;font-size:0.85rem;color:var(--ink);">Agent Permission</div>
1334
+ <span class="preview-tier-badge">${esc(tierName)} Tier</span>
1335
+ </div>
1336
+ </div>
1337
+ <div class="preview-summary">
1338
+ This agent can discuss ${topicText} to help ${goalText} using <strong>${toolCount} tool${toolCount !== 1 ? 's' : ''}</strong>.
1339
+ </div>
1340
+ <div class="preview-footer">
1341
+ <span style="display:flex;align-items:center;gap:0.3rem;">
1342
+ <span class="status-dot status-dot--green"></span> Active
1343
+ </span>
1344
+ <span style="font-family:monospace;opacity:0.7;">JSON Valid</span>
1345
+ </div>
1346
+ </div>
1347
+ `;
1348
+ }
1349
+
1350
+ // A2A-48: Renders all topics and goals in the right sidebar as draggable items.
1351
+ // Items active in the current tier are dimmed with an "(Active)" label.
1352
+ function renderSidebarLists(tier) {
1353
+ const topicContainer = document.getElementById('sidebar-topics');
1354
+ const goalContainer = document.getElementById('sidebar-goals');
1355
+ if (!topicContainer || !goalContainer) return;
1356
+
1357
+ // Collect ALL topics across ALL tiers for the sidebar
1358
+ const allTiers = state.settings?.tiers || [];
1359
+ const allTopicMap = new Map();
1360
+ const allGoalMap = new Map();
1361
+ for (const t of allTiers) {
1362
+ const mTopics = t.manifest?.topics || [];
1363
+ const fTopics = t.topics || [];
1364
+ const topics = mTopics.length > 0 ? mTopics : fTopics.map(x => ({ topic: x, description: '' }));
1365
+ for (const item of topics) {
1366
+ if (item.topic && !allTopicMap.has(item.topic)) {
1367
+ allTopicMap.set(item.topic, item.description || '');
1368
+ }
1369
+ }
1370
+ const mGoals = t.manifest?.objectives || [];
1371
+ const fGoals = t.goals || [];
1372
+ const goals = mGoals.length > 0 ? mGoals : fGoals.map(x => ({ topic: x, description: '' }));
1373
+ for (const item of goals) {
1374
+ const label = item.objective || item.topic;
1375
+ if (label && !allGoalMap.has(label)) {
1376
+ allGoalMap.set(label, item.description || '');
1377
+ }
1370
1378
  }
1379
+ }
1371
1380
 
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>
1381
+ // Determine which are active in the current tier
1382
+ const activeTopicSet = new Set((tier.topics || []).concat(
1383
+ (tier.manifest?.topics || []).map(t => t.topic)
1384
+ ).filter(Boolean));
1385
+ const activeGoalSet = new Set((tier.goals || []).concat(
1386
+ (tier.manifest?.objectives || []).map(g => g.objective || g.topic)
1387
+ ).filter(Boolean));
1388
+
1389
+ // Render topics sidebar
1390
+ const topicItems = Array.from(allTopicMap.entries()).map(([name, desc]) => {
1391
+ const isActive = activeTopicSet.has(name);
1392
+ return `
1393
+ <div class="sidebar-item${isActive ? ' active-in-zone' : ''}" draggable="${isActive ? 'false' : 'true'}" data-sidebar-topic="${esc(name)}" data-description="${esc(desc)}" data-item-type="topic">
1394
+ <div style="display:flex;align-items:center;gap:0.4rem;">
1395
+ ${isActive ? '' : '<span class="material-symbols-outlined" style="color:#4B5563;font-size:1rem;cursor:grab;">drag_indicator</span>'}
1396
+ <span class="sidebar-item-name">${esc(name)}${isActive ? ' <span class="sidebar-item-active-label">(Active)</span>' : ''}</span>
1383
1397
  </div>
1384
1398
  </div>
1385
- `).join('');
1399
+ `;
1400
+ }).join('');
1401
+
1402
+ topicContainer.innerHTML = `
1403
+ <div class="sidebar-list-header">Topics</div>
1404
+ ${topicItems}
1405
+ <button class="sidebar-add-btn" data-add-type="topic">
1406
+ <span class="material-symbols-outlined" style="font-size:14px;">add</span> Add Topic
1407
+ </button>
1408
+ `;
1386
1409
 
1410
+ // Render goals sidebar
1411
+ const goalItems = Array.from(allGoalMap.entries()).map(([name, desc]) => {
1412
+ const isActive = activeGoalSet.has(name);
1387
1413
  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}
1414
+ <div class="sidebar-item${isActive ? ' active-in-zone' : ''}" draggable="${isActive ? 'false' : 'true'}" data-sidebar-goal="${esc(name)}" data-description="${esc(desc)}" data-item-type="goal">
1415
+ <div style="display:flex;align-items:center;gap:0.4rem;">
1416
+ ${isActive ? '' : '<span class="material-symbols-outlined" style="color:#4B5563;font-size:1rem;cursor:grab;">drag_indicator</span>'}
1417
+ <span class="sidebar-item-name">${esc(name)}${isActive ? ' <span class="sidebar-item-active-label">(Active)</span>' : ''}</span>
1392
1418
  </div>
1393
- </div>`;
1419
+ </div>
1420
+ `;
1394
1421
  }).join('');
1395
1422
 
1396
- container.innerHTML = html;
1397
- bindDragEvents();
1423
+ goalContainer.innerHTML = `
1424
+ <div class="sidebar-list-header">Goals &amp; Objectives</div>
1425
+ ${goalItems}
1426
+ <button class="sidebar-add-btn" data-add-type="goal">
1427
+ <span class="material-symbols-outlined" style="font-size:14px;">add</span> Add Goal
1428
+ </button>
1429
+ `;
1398
1430
  }
1399
1431
 
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');
1432
+ // A2A-48: Debounced auto-save replaces explicit Save Tier button.
1433
+ // 250ms delay prevents excessive API calls during rapid changes.
1434
+ let _autoSaveTimer = null;
1435
+ function autoSaveTier() {
1436
+ clearTimeout(_autoSaveTimer);
1437
+ _autoSaveTimer = setTimeout(async () => {
1438
+ const tierId = state.activeTierId;
1439
+ if (!tierId) return;
1440
+
1441
+ // Collect tools from toggle states
1442
+ const toggles = document.querySelectorAll('#tool-toggles .toggle-switch input');
1443
+ const allowed_tools = Array.from(toggles).filter(t => t.checked).map(t => t.dataset.tool);
1444
+
1445
+ // Collect topics from active zone
1446
+ const topicCards = document.querySelectorAll('#active-topics-zone .active-item-card');
1447
+ // A2A-48: uses dataset.topic for BOTH topics and goals (NOT dataset.objective)
1448
+ // because parseTopicObjects() in dashboard.js:160 only reads entry.topic.
1449
+ // The semantic distinction (objective vs topic) is UI-only; storage layer
1450
+ // uses {topic, description} uniformly for both types.
1451
+ const topics = Array.from(topicCards).map(c => c.dataset.topic).filter(Boolean);
1452
+ const manifestTopics = Array.from(topicCards).map(c => ({
1453
+ topic: c.dataset.topic,
1454
+ description: c.dataset.description || ''
1455
+ })).filter(t => t.topic);
1456
+
1457
+ // Collect goals from active zone
1458
+ const goalCards = document.querySelectorAll('#active-goals-zone .active-item-card');
1459
+ const goals = Array.from(goalCards).map(c => c.dataset.topic).filter(Boolean);
1460
+ const manifestObjectives = Array.from(goalCards).map(c => ({
1461
+ topic: c.dataset.topic,
1462
+ description: c.dataset.description || ''
1463
+ })).filter(g => g.topic);
1464
+
1465
+ const body = { allowed_tools, topics, goals, manifest: { topics: manifestTopics, objectives: manifestObjectives } };
1466
+ // A2A-48: Refresh state inside try block so that a failed PUT does not
1467
+ // trigger an unhandled rejection from the subsequent GET.
1468
+ try {
1469
+ await request(`/settings/tiers/${encodeURIComponent(tierId)}`, {
1470
+ method: 'PUT', body: JSON.stringify(body)
1471
+ });
1472
+ showNotice('Saved');
1473
+ // Refresh state from server to stay in sync after auto-save
1474
+ const payload = await request('/settings');
1475
+ state.settings = payload;
1476
+ } catch (err) {
1477
+ showNotice(`Save failed: ${err.message}`);
1478
+ }
1479
+ }, 250);
1480
+ }
1405
1481
 
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');
1482
+ // A2A-48: Binds dragstart/dragend on sidebar items (re-created each render).
1483
+ // Zone listeners (dragover/dragleave/drop) are bound ONCE in
1484
+ // bindPermissionsActions() to avoid listener accumulation — the zone
1485
+ // containers persist across renders while only their innerHTML changes.
1486
+ function bindSidebarDrag() {
1487
+ document.querySelectorAll('.sidebar-item[draggable="true"]').forEach(item => {
1488
+ item.addEventListener('dragstart', (e) => {
1489
+ const itemType = item.dataset.itemType || 'topic';
1490
+ const name = item.dataset.sidebarTopic || item.dataset.sidebarGoal || '';
1491
+ const desc = item.dataset.description || '';
1492
+ e.dataTransfer.setData('application/json', JSON.stringify({ name, description: desc, type: itemType }));
1493
+ item.style.opacity = '0.5';
1413
1494
  });
1414
- row.addEventListener('dragend', () => row.classList.remove('dragging'));
1495
+ item.addEventListener('dragend', () => { item.style.opacity = ''; });
1415
1496
  });
1497
+ }
1416
1498
 
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');
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.
1503
+ function handleZoneDrop(zone, e) {
1504
+ e.preventDefault();
1505
+ zone.classList.remove('drag-over');
1506
+ let data;
1507
+ try { data = JSON.parse(e.dataTransfer.getData('application/json')); } catch { return; }
1508
+ if (!data.name) return;
1509
+
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;
1516
+
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');
1523
+ for (const card of existing) {
1524
+ if (card.dataset.topic === data.name) return; // already active
1525
+ }
1426
1526
 
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;
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();
1431
1533
 
1432
- if (!topic || !sourceTier || !targetTier || sourceTier === targetTier) return;
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) renderSidebarLists(freshTier);
1539
+ }, 300);
1540
+ }
1541
+
1542
+ // A2A-50: Shared helper to build an active-item card DOM element.
1543
+ // Used by both handleZoneDrop() and the create-item-submit handler
1544
+ // to avoid duplicating card HTML in two places.
1545
+ function buildItemCard(name, description, accentClass, typeLabel, removeAttr) {
1546
+ const card = document.createElement('div');
1547
+ card.className = `active-item-card ${accentClass}`;
1548
+ card.dataset.topic = name;
1549
+ card.dataset.description = description;
1550
+ card.innerHTML = `
1551
+ <div>
1552
+ <div class="item-name">${esc(name)}</div>
1553
+ <div class="item-type-label">${typeLabel}</div>
1554
+ </div>
1555
+ <button class="item-close-btn" ${removeAttr}="${esc(name)}">
1556
+ <span class="material-symbols-outlined" style="font-size:16px;">close</span>
1557
+ </button>
1558
+ `;
1559
+ return card;
1560
+ }
1433
1561
 
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;
1562
+ // A2A-50: Populates tier select dropdowns: #invite-tier (Invites tab),
1563
+ // #new-tier-copy-from (Settings tab), and #new-tier-dialog-copy-from
1564
+ // (new tier modal in Permissions tab).
1565
+ function populateInviteTierSelect() {
1566
+ const tiers = (state.settings?.tiers || []).slice().sort((a, b) => a.id.localeCompare(b.id));
1567
+ const newTierCopy = document.getElementById('new-tier-copy-from');
1568
+ const newTierDialogCopy = document.getElementById('new-tier-dialog-copy-from');
1569
+ const inviteTier = document.getElementById('invite-tier');
1437
1570
 
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: '' }];
1571
+ const optionsHtml = tiers.map(tier => {
1572
+ const emoji = TIER_EMOJIS[tier.id] || '\u{1F527}';
1573
+ return `<sl-option value="${esc(tier.id)}">${emoji} ${esc(tier.name || tier.id)}</sl-option>`;
1574
+ }).join('');
1443
1575
 
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
- });
1576
+ if (inviteTier) inviteTier.innerHTML = optionsHtml;
1577
+ if (newTierCopy) newTierCopy.innerHTML = `<sl-option value="">None</sl-option>${optionsHtml}`;
1578
+ // A2A-50: Also populate the Copy From select inside the new-tier-dialog modal
1579
+ if (newTierDialogCopy) newTierDialogCopy.innerHTML = `<sl-option value="">None</sl-option>${optionsHtml}`;
1580
+
1581
+ // A2A-48: Default invite tier to 'public'
1582
+ const defaultTier = tiers.find(t => t.id === 'public') ? 'public' : tiers[0]?.id;
1583
+ if (defaultTier && inviteTier) inviteTier.value = defaultTier;
1470
1584
  }
1471
1585
 
1472
1586
  // A2A-41: contextual validation warnings for the currently selected tier.
@@ -1543,10 +1657,10 @@ function getPreviewData(tierId) {
1543
1657
  return merged;
1544
1658
  }
1545
1659
 
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.
1660
+ // A2A-48: opens the caller preview dialog showing the merged effective view
1661
+ // for the selected tier. Uses state.activeTierId instead of removed #tier-select.
1548
1662
  function openCallerPreview() {
1549
- const tierId = document.getElementById('tier-select').value;
1663
+ const tierId = state.activeTierId;
1550
1664
  const data = getPreviewData(tierId);
1551
1665
  const emoji = TIER_EMOJIS[tierId] || '\u{1F527}';
1552
1666
  const tierName = (state.settings?.tiers || []).find(t => t.id === tierId)?.name || tierId;
@@ -1590,125 +1704,235 @@ function openCallerPreview() {
1590
1704
  dialog.show();
1591
1705
  }
1592
1706
 
1593
- function bindSettingsActions() {
1594
- document.getElementById('tier-select').addEventListener('sl-change', (e) => {
1595
- renderTierEditor(e.target.value);
1596
- });
1707
+ // A2A-50: Shows the delete confirmation dialog for a topic/goal card.
1708
+ // Uses a unique marker attribute on the card so the confirm handler can
1709
+ // find and remove it. This avoids storing DOM references in closure state.
1710
+ let _deleteIdCounter = 0;
1711
+ function showDeleteConfirm(itemName, itemType, cardElement) {
1712
+ const dialog = document.getElementById('delete-confirm-dialog');
1713
+ if (!dialog) return;
1714
+ const label = itemType === 'topic' ? 'Topic' : 'Goal';
1715
+ const msgEl = document.getElementById('delete-confirm-message');
1716
+ if (msgEl) msgEl.textContent = `Remove ${label} "${itemName}" from this tier?`;
1717
+ // Tag the card with a unique ID so the confirm handler can find it
1718
+ const deleteId = `del-${++_deleteIdCounter}`;
1719
+ cardElement.setAttribute('data-delete-id', deleteId);
1720
+ dialog.dataset.deleteCardId = deleteId;
1721
+ dialog.show();
1722
+ }
1597
1723
 
1598
- document.getElementById('tier-form').addEventListener('submit', async (e) => {
1599
- e.preventDefault();
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);
1724
+ // A2A-48: Binds all event handlers for the permissions panel. Replaces old
1725
+ // bindSettingsActions() — removes handlers for deleted elements (tier-form,
1726
+ // tier-select, copy-tier-btn, show-drag-columns, preview-caller-btn) and
1727
+ // adds handlers for tier cards, tool toggles, close buttons, and sidebar.
1728
+ function bindPermissionsActions() {
1729
+ const panel = document.getElementById('panel-permissions');
1730
+ if (!panel) return;
1731
+
1732
+ // A2A-48: Tier card click — switch active tier and re-render.
1733
+ // No autoSaveTier() here: switching tiers is a read operation, not a write.
1734
+ panel.addEventListener('click', (e) => {
1735
+ const card = e.target.closest('.tier-card[data-tier-id]');
1736
+ if (card) {
1737
+ state.activeTierId = card.dataset.tierId;
1738
+ renderPermissions();
1739
+ return;
1740
+ }
1615
1741
 
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);
1742
+ // A2A-50: Close button on active topic cards opens delete confirmation dialog
1743
+ const removeTopic = e.target.closest('[data-remove-topic]');
1744
+ if (removeTopic) {
1745
+ const card = removeTopic.closest('.active-item-card');
1746
+ if (card) {
1747
+ const itemName = card.dataset.topic || '';
1748
+ showDeleteConfirm(itemName, 'topic', card);
1749
+ }
1750
+ return;
1751
+ }
1626
1752
 
1627
- const body = {
1628
- name: document.getElementById('tier-name').value,
1629
- description: document.getElementById('tier-description').value,
1630
- allowed_tools,
1631
- topics,
1632
- goals,
1633
- manifest: {
1634
- topics: manifestTopics,
1635
- objectives: manifestObjectives
1753
+ // A2A-50: Close button on active goal cards opens delete confirmation dialog
1754
+ const removeGoal = e.target.closest('[data-remove-goal]');
1755
+ if (removeGoal) {
1756
+ const card = removeGoal.closest('.active-item-card');
1757
+ if (card) {
1758
+ const itemName = card.dataset.topic || '';
1759
+ showDeleteConfirm(itemName, 'goal', card);
1636
1760
  }
1637
- };
1638
- await request(`/settings/tiers/${encodeURIComponent(tierId)}`, {
1639
- method: 'PUT',
1640
- body: JSON.stringify(body)
1641
- });
1642
- showNotice(`Saved tier "${tierId}"`);
1643
- await loadSettings();
1761
+ return;
1762
+ }
1763
+
1764
+ // A2A-50: "+ New Tier" button opens glass-styled sl-dialog instead of
1765
+ // scrolling to inline form. Keeps focus trap and accessibility from Shoelace.
1766
+ const newTierBtn = e.target.closest('#perm-new-tier-btn');
1767
+ if (newTierBtn) {
1768
+ const dialog = document.getElementById('new-tier-dialog');
1769
+ if (dialog) {
1770
+ const idInput = document.getElementById('new-tier-dialog-id');
1771
+ const nameInput = document.getElementById('new-tier-dialog-name');
1772
+ if (idInput) idInput.value = '';
1773
+ if (nameInput) nameInput.value = '';
1774
+ dialog.show();
1775
+ }
1776
+ return;
1777
+ }
1778
+
1779
+ // A2A-48: Sidebar "Add Topic" / "Add Goal" buttons open create dialog
1780
+ const addBtn = e.target.closest('.sidebar-add-btn[data-add-type]');
1781
+ if (addBtn) {
1782
+ const type = addBtn.dataset.addType;
1783
+ const dialog = document.getElementById('create-item-dialog');
1784
+ if (dialog) {
1785
+ dialog.label = `Create New ${type === 'topic' ? 'Topic' : 'Goal'}`;
1786
+ dialog.dataset.createType = type;
1787
+ const titleInput = document.getElementById('create-item-title');
1788
+ const descInput = document.getElementById('create-item-desc');
1789
+ if (titleInput) titleInput.value = '';
1790
+ if (descInput) descInput.value = '';
1791
+ dialog.show();
1792
+ }
1793
+ return;
1794
+ }
1644
1795
  });
1645
1796
 
1646
- document.getElementById('copy-tier-btn').addEventListener('click', async () => {
1647
- const toTier = document.getElementById('tier-select').value;
1648
- const fromTier = document.getElementById('copy-from-tier').value;
1649
- if (!toTier || !fromTier || toTier === fromTier) return;
1650
- await request(`/settings/tiers/${encodeURIComponent(toTier)}/copy-from/${encodeURIComponent(fromTier)}`, {
1651
- method: 'POST'
1652
- });
1653
- showNotice(`Copied "${fromTier}" -> "${toTier}"`);
1654
- await loadSettings();
1655
- renderTierEditor(toTier);
1797
+ // A2A-48: Drop zone listeners — bound ONCE here because the zone containers
1798
+ // (#active-topics-zone, #active-goals-zone) persist across renders. Only
1799
+ // their innerHTML is replaced by renderActiveTopics/renderActiveGoals.
1800
+ // Binding in bindSidebarDrag() would cause listener accumulation.
1801
+ const topicZone = document.getElementById('active-topics-zone');
1802
+ const goalZone = document.getElementById('active-goals-zone');
1803
+ [topicZone, goalZone].forEach(zone => {
1804
+ if (!zone) return;
1805
+ zone.addEventListener('dragover', (e) => { e.preventDefault(); zone.classList.add('drag-over'); });
1806
+ zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
1807
+ zone.addEventListener('drop', (e) => handleZoneDrop(zone, e));
1656
1808
  });
1657
1809
 
1658
- document.getElementById('defaults-form').addEventListener('submit', async (e) => {
1659
- e.preventDefault();
1660
- await request('/settings/defaults', {
1661
- method: 'PUT',
1662
- body: JSON.stringify({
1663
- expiration: document.getElementById('defaults-expiration').value,
1664
- maxCalls: Number.parseInt(document.getElementById('defaults-max-calls').value, 10) || 100
1665
- })
1666
- });
1667
- showNotice('Saved defaults');
1668
- await loadSettings();
1810
+ // A2A-48: Tool toggle change — auto-save and update card styling
1811
+ panel.addEventListener('change', (e) => {
1812
+ const toggle = e.target.closest('#tool-toggles .toggle-switch input');
1813
+ if (toggle) {
1814
+ const card = toggle.closest('.tool-toggle-card');
1815
+ if (card) {
1816
+ card.classList.toggle('enabled', toggle.checked);
1817
+ }
1818
+ autoSaveTier();
1819
+ return;
1820
+ }
1669
1821
  });
1670
1822
 
1671
- document.getElementById('new-tier-btn').addEventListener('click', () => {
1672
- document.getElementById('new-tier-id').focus();
1823
+ // A2A-50: Create Item dialog — submit handler. Uses shared buildItemCard()
1824
+ // helper to avoid duplicating card HTML with handleZoneDrop().
1825
+ document.getElementById('create-item-submit')?.addEventListener('click', () => {
1826
+ const dialog = document.getElementById('create-item-dialog');
1827
+ const titleInput = document.getElementById('create-item-title');
1828
+ const descInput = document.getElementById('create-item-desc');
1829
+ if (!dialog || !titleInput) return;
1830
+
1831
+ const title = titleInput.value.trim();
1832
+ if (!title) { titleInput.focus(); return; }
1833
+ const desc = descInput?.value?.trim() || '';
1834
+ const type = dialog.dataset.createType || 'topic';
1835
+
1836
+ const zoneId = type === 'topic' ? 'active-topics-zone' : 'active-goals-zone';
1837
+ const zone = document.getElementById(zoneId);
1838
+ if (!zone) return;
1839
+
1840
+ const accentClass = type === 'topic' ? 'active-item-card--teal' : 'active-item-card--yellow';
1841
+ const typeLabel = type === 'topic' ? 'Topic' : 'Goal';
1842
+ const removeAttr = type === 'topic' ? 'data-remove-topic' : 'data-remove-goal';
1843
+
1844
+ const card = buildItemCard(title, desc, accentClass, typeLabel, removeAttr);
1845
+ const placeholder = zone.querySelector('.drop-placeholder');
1846
+ if (placeholder) zone.insertBefore(card, placeholder);
1847
+ else zone.appendChild(card);
1848
+
1849
+ dialog.hide();
1850
+ autoSaveTier();
1673
1851
  });
1674
1852
 
1675
- document.getElementById('new-tier-form').addEventListener('submit', async (e) => {
1676
- e.preventDefault();
1677
- const tierId = document.getElementById('new-tier-id').value.trim();
1678
- const name = document.getElementById('new-tier-name').value.trim();
1679
- const copyFrom = document.getElementById('new-tier-copy-from').value;
1680
- if (!tierId) return;
1681
- await request('/settings/tiers', {
1682
- method: 'POST',
1683
- body: JSON.stringify({
1684
- id: tierId,
1685
- name: name || tierId,
1686
- copy_from: copyFrom || undefined
1687
- })
1688
- });
1689
- showNotice(`Created tier "${tierId}"`);
1690
- document.getElementById('new-tier-form').reset();
1691
- await loadSettings();
1692
- document.getElementById('tier-select').value = tierId;
1693
- renderTierEditor(tierId);
1853
+ // A2A-48: Create Item dialog — cancel handler
1854
+ document.getElementById('create-item-cancel')?.addEventListener('click', () => {
1855
+ document.getElementById('create-item-dialog')?.hide();
1694
1856
  });
1695
1857
 
1696
- // A2A-41: toggle for three-column tier view
1697
- document.getElementById('show-drag-columns')?.addEventListener('sl-change', () => {
1698
- renderTierColumns();
1858
+ // A2A-50: New Tier dialog submit handler (replaces inline form handler).
1859
+ // Creates tier via POST and switches to it.
1860
+ document.getElementById('new-tier-dialog-submit')?.addEventListener('click', async () => {
1861
+ const tierId = document.getElementById('new-tier-dialog-id')?.value?.trim();
1862
+ const name = document.getElementById('new-tier-dialog-name')?.value?.trim();
1863
+ const copyFrom = document.getElementById('new-tier-dialog-copy-from')?.value;
1864
+ if (!tierId) {
1865
+ document.getElementById('new-tier-dialog-id')?.focus();
1866
+ return;
1867
+ }
1868
+ try {
1869
+ await request('/settings/tiers', {
1870
+ method: 'POST',
1871
+ body: JSON.stringify({
1872
+ id: tierId,
1873
+ name: name || tierId,
1874
+ copy_from: copyFrom || undefined
1875
+ })
1876
+ });
1877
+ showNotice(`Created tier "${tierId}"`);
1878
+ document.getElementById('new-tier-dialog')?.hide();
1879
+ await loadSettings();
1880
+ state.activeTierId = tierId;
1881
+ renderPermissions();
1882
+ } catch (err) {
1883
+ showNotice(err.message);
1884
+ }
1885
+ });
1886
+
1887
+ // A2A-50: New Tier dialog — cancel handler
1888
+ document.getElementById('new-tier-dialog-cancel')?.addEventListener('click', () => {
1889
+ document.getElementById('new-tier-dialog')?.hide();
1699
1890
  });
1700
1891
 
1701
- document.getElementById('preview-caller-btn')?.addEventListener('click', openCallerPreview);
1892
+ // A2A-50: Delete confirm dialog — confirm handler. Removes the card that
1893
+ // was stored in dataset and triggers auto-save.
1894
+ document.getElementById('delete-confirm-yes')?.addEventListener('click', () => {
1895
+ const dialog = document.getElementById('delete-confirm-dialog');
1896
+ if (!dialog) return;
1897
+ const cardId = dialog.dataset.deleteCardId;
1898
+ if (cardId) {
1899
+ const card = document.querySelector(`.active-item-card[data-delete-id="${cardId}"]`);
1900
+ if (card) {
1901
+ card.removeAttribute('data-delete-id');
1902
+ card.remove();
1903
+ }
1904
+ }
1905
+ dialog.hide();
1906
+ autoSaveTier();
1907
+ });
1908
+
1909
+ // A2A-50: Delete confirm dialog — cancel handler
1910
+ document.getElementById('delete-confirm-no')?.addEventListener('click', () => {
1911
+ const dialog = document.getElementById('delete-confirm-dialog');
1912
+ if (dialog) {
1913
+ // Clean up the marker attribute from the card
1914
+ const cardId = dialog.dataset.deleteCardId;
1915
+ if (cardId) {
1916
+ const card = document.querySelector(`.active-item-card[data-delete-id="${cardId}"]`);
1917
+ if (card) card.removeAttribute('data-delete-id');
1918
+ }
1919
+ dialog.hide();
1920
+ }
1921
+ });
1922
+
1923
+ // Preview dialog close — unchanged
1702
1924
  document.getElementById('preview-close-btn')?.addEventListener('click', () => {
1703
1925
  document.getElementById('preview-dialog').hide();
1704
1926
  });
1705
1927
  }
1706
1928
 
1929
+ // A2A-48: Load settings and render permissions. Replaces fillTierSelects() and
1930
+ // renderTierColumns() calls with populateInviteTierSelect() + renderPermissions().
1707
1931
  async function loadSettings() {
1708
1932
  const payload = await request('/settings');
1709
1933
  state.settings = payload;
1710
- fillTierSelects();
1711
- renderTierColumns();
1934
+ populateInviteTierSelect();
1935
+ renderPermissions();
1712
1936
  document.getElementById('defaults-expiration').value = payload.defaults?.expiration || '7d';
1713
1937
  document.getElementById('defaults-max-calls').value = payload.defaults?.maxCalls || 100;
1714
1938
  }
@@ -2066,21 +2290,22 @@ function bindLogFilterRefresh() {
2066
2290
 
2067
2291
  let pollTimer = null;
2068
2292
 
2293
+ // A2A-47: Simply return tracked state instead of querying sl-tab-group
2069
2294
  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';
2295
+ return state.activeTab || 'contacts';
2075
2296
  }
2076
2297
 
2077
2298
  const tabLoaders = {
2078
2299
  contacts: loadContacts,
2079
2300
  calls: loadCalls,
2080
2301
  logs: () => { loadLogs(); loadLogStats(); },
2081
- permissions: () => {},
2302
+ // A2A-48: Load fresh settings data when switching to Permissions tab.
2303
+ // Previously a no-op — now ensures data is current on tab switch.
2304
+ permissions: loadSettings,
2082
2305
  invites: loadInvites,
2083
2306
  health: loadHealth,
2307
+ // A2A-50: Settings tab loads dashboard status, auto-update, and callbook data
2308
+ settings: () => { loadDashboardStatus(); loadAutoUpdateStatus(); loadCallbookDevices(); },
2084
2309
  };
2085
2310
 
2086
2311
  function startPolling() {
@@ -2199,11 +2424,64 @@ function renderHealthHistory(history) {
2199
2424
  }).join('');
2200
2425
  }
2201
2426
 
2427
+ // A2A-50: Binds handlers for the Settings panel (defaults form, new-tier form).
2428
+ // These were previously inside bindPermissionsActions() but are now in the
2429
+ // separate #panel-settings panel.
2430
+ function bindSettingsActions() {
2431
+ // Defaults form
2432
+ document.getElementById('defaults-form')?.addEventListener('submit', async (e) => {
2433
+ e.preventDefault();
2434
+ try {
2435
+ await request('/settings/defaults', {
2436
+ method: 'PUT',
2437
+ body: JSON.stringify({
2438
+ expiration: document.getElementById('defaults-expiration').value,
2439
+ maxCalls: Number.parseInt(document.getElementById('defaults-max-calls').value, 10) || 100
2440
+ })
2441
+ });
2442
+ showNotice('Saved defaults');
2443
+ await loadSettings();
2444
+ } catch (err) {
2445
+ showNotice(err.message);
2446
+ }
2447
+ });
2448
+
2449
+ // New Tier form (inline form in Settings tab, kept for non-modal access)
2450
+ document.getElementById('new-tier-form')?.addEventListener('submit', async (e) => {
2451
+ e.preventDefault();
2452
+ const tierId = document.getElementById('new-tier-id')?.value?.trim();
2453
+ const name = document.getElementById('new-tier-name')?.value?.trim();
2454
+ const copyFrom = document.getElementById('new-tier-copy-from')?.value;
2455
+ if (!tierId) return;
2456
+ try {
2457
+ await request('/settings/tiers', {
2458
+ method: 'POST',
2459
+ body: JSON.stringify({
2460
+ id: tierId,
2461
+ name: name || tierId,
2462
+ copy_from: copyFrom || undefined
2463
+ })
2464
+ });
2465
+ showNotice(`Created tier "${tierId}"`);
2466
+ document.getElementById('new-tier-form')?.reset();
2467
+ await loadSettings();
2468
+ state.activeTierId = tierId;
2469
+ renderPermissions();
2470
+ } catch (err) {
2471
+ showNotice(err.message);
2472
+ }
2473
+ });
2474
+ }
2475
+
2202
2476
  async function bootstrap() {
2203
2477
  bindTabs();
2204
2478
  bindContactsActions();
2479
+ // A2A-48: bindPermissionsActions() replaces old bindSettingsActions() +
2480
+ // bindItemListDelegation(). All tier/tool/topic/goal handlers are now
2481
+ // inside bindPermissionsActions() using event delegation on #panel-permissions.
2482
+ bindPermissionsActions();
2483
+ // A2A-50: Settings panel handlers (defaults, new-tier inline form)
2205
2484
  bindSettingsActions();
2206
- bindItemListDelegation();
2207
2485
  bindCallbookActions();
2208
2486
  bindAutoUpdateActions();
2209
2487
  bindInviteActions();