a2acalling 0.6.63 → 0.6.64

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,77 @@ 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
+ const panelTitles = {
259
+ contacts: 'Contacts',
260
+ calls: 'Calls',
261
+ permissions: 'Permissions',
262
+ invites: 'Invites',
263
+ logs: 'Logs',
264
+ health: 'Health'
265
+ };
255
266
 
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);
267
+ // A2A-47: Show a specific panel and update sidebar + header state.
268
+ // Replaces the old sl-tab-group navigation.
269
+ function showPanel(name) {
270
+ const validPanels = Object.keys(panelTitles);
271
+ if (!validPanels.includes(name)) name = 'contacts';
272
+
273
+ // Hide all panels, show the target
274
+ document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
275
+ const target = document.getElementById('panel-' + name);
276
+ if (target) target.classList.add('active');
277
+
278
+ // Update sidebar active state
279
+ document.querySelectorAll('.nav-item').forEach(item => {
280
+ if (item.dataset.panel === name) {
281
+ item.classList.add('active');
282
+ } else {
283
+ item.classList.remove('active');
284
+ }
260
285
  });
261
286
 
262
- // Deep-link support: activate the tab matching the URL hash
287
+ // Update header title
288
+ const titleEl = document.getElementById('section-title');
289
+ if (titleEl) titleEl.textContent = panelTitles[name] || name;
290
+
291
+ // Update state and hash
292
+ state.activeTab = name;
293
+ try {
294
+ if (window.location.hash.slice(1) !== name) {
295
+ window.location.hash = name;
296
+ }
297
+ } catch (err) {}
298
+
299
+ // Trigger data loading for the active tab
300
+ if (typeof onTabSwitch === 'function') onTabSwitch(name);
301
+ }
302
+
303
+ function bindTabs() {
304
+ // A2A-47: Sidebar nav click handler
305
+ document.querySelectorAll('.nav-item').forEach(item => {
306
+ item.addEventListener('click', (e) => {
307
+ e.preventDefault();
308
+ const panel = item.dataset.panel;
309
+ if (panel) showPanel(panel);
310
+ });
311
+ });
312
+
313
+ // Deep-link support: activate the panel matching the URL hash
263
314
  const activateFromHash = () => {
264
315
  let hash = window.location.hash.slice(1);
265
316
  // A2A-41: backward-compat alias — old bookmarks/links using #settings
266
317
  // still work after rename to #permissions
267
318
  if (hash === 'settings') hash = 'permissions';
268
319
  if (hash) {
269
- // Use try/catch in case the tab group isn't fully ready
270
- try { tabGroup.show(hash); } catch (err) {}
320
+ showPanel(hash);
271
321
  }
272
322
  };
273
323
 
274
324
  window.addEventListener('hashchange', activateFromHash);
275
325
 
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
- }
326
+ // On initial load, activate from hash
327
+ activateFromHash();
283
328
  }
284
329
 
285
330
  function norm(value) {
@@ -393,7 +438,7 @@ function renderContacts() {
393
438
  </td>
394
439
  ${locationCell}
395
440
  ${ownerCell}
396
- <td>${esc(c?.status || '-')}</td>
441
+ <td><span class="contact-status" data-status="${esc(c?.status || 'unknown')}">${esc(c?.status || '-')}</span></td>
397
442
  <td>${esc(String(calls))}</td>
398
443
  <td>${esc(lastCallAt)}</td>
399
444
  ${summaryCell}
@@ -562,8 +607,8 @@ function bindContactsActions() {
562
607
  }
563
608
  });
564
609
 
565
- // Event delegation on the contacts tab panel
566
- const panel = document.querySelector('sl-tab-panel[name="contacts"]');
610
+ // A2A-47: Event delegation on the contacts panel (was sl-tab-panel, now div#panel-contacts)
611
+ const panel = document.querySelector('#panel-contacts');
567
612
  panel?.addEventListener('click', async (e) => {
568
613
  const pinBtn = e.target.closest('[data-pin-contact]');
569
614
  if (pinBtn) {
@@ -1062,7 +1107,7 @@ function renderLogs() {
1062
1107
  const trace = row.trace_id || '';
1063
1108
  tr.innerHTML = `
1064
1109
  <td>${esc(fmtDate(row.timestamp))}</td>
1065
- <td>${esc(row.level || '-')}</td>
1110
+ <td><span class="log-level" data-level="${esc(row.level || '')}">${esc(row.level || '-')}</span></td>
1066
1111
  <td>${esc(row.component || '-')}</td>
1067
1112
  <td>${esc(row.event || '-')}</td>
1068
1113
  <td title="${esc(row.message || '')}">${esc(String(row.message || '').slice(0, 120) || '-')}</td>
@@ -1117,53 +1162,113 @@ const TOOL_DESCRIPTIONS = {
1117
1162
  // A2A-41: standard tier order for inheritance. Custom tiers are not in this list.
1118
1163
  const TIER_ORDER = ['public', 'friends', 'family'];
1119
1164
 
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');
1165
+ // A2A-48: Material icon mapping for tier cards. Standard tiers get recognizable
1166
+ // icons; custom tiers get a wrench icon. Used by renderTierCards().
1167
+ const TIER_ICONS = { public: 'public', friends: 'group', family: 'family_restroom' };
1168
+
1169
+ // A2A-48: Color mapping for tool icons in toggle cards. Gives each tool a
1170
+ // distinct color matching the concept mock's visual differentiation.
1171
+ const TOOL_ICON_MAP = {
1172
+ 'Bash': { icon: 'terminal', bg: 'rgba(99,102,241,0.2)', color: '#818CF8', border: 'rgba(99,102,241,0.2)' },
1173
+ 'Bash(readonly)': { icon: 'terminal', bg: 'rgba(99,102,241,0.15)', color: '#A5B4FC', border: 'rgba(99,102,241,0.15)' },
1174
+ 'Read': { icon: 'visibility', bg: 'rgba(59,130,246,0.2)', color: '#60A5FA', border: 'rgba(59,130,246,0.2)' },
1175
+ 'Grep': { icon: 'search', bg: 'rgba(139,92,246,0.2)', color: '#A78BFA', border: 'rgba(139,92,246,0.2)' },
1176
+ 'Glob': { icon: 'folder_open', bg: 'rgba(16,185,129,0.2)', color: '#34D399', border: 'rgba(16,185,129,0.2)' },
1177
+ 'WebSearch': { icon: 'public', bg: 'rgba(245,158,11,0.2)', color: '#FBBF24', border: 'rgba(245,158,11,0.2)' },
1178
+ 'WebFetch': { icon: 'language', bg: 'rgba(236,72,153,0.2)', color: '#F472B6', border: 'rgba(236,72,153,0.2)' }
1179
+ };
1180
+
1181
+ // A2A-48: Renders tier cards grid. Active card gets .active class with glow.
1182
+ function renderTierCards() {
1183
+ const container = document.getElementById('tier-cards');
1184
+ if (!container) return;
1185
+ const tiers = (state.settings?.tiers || []).slice().sort((a, b) => {
1186
+ const aIdx = TIER_ORDER.indexOf(a.id);
1187
+ const bIdx = TIER_ORDER.indexOf(b.id);
1188
+ if (aIdx >= 0 && bIdx >= 0) return aIdx - bIdx;
1189
+ if (aIdx >= 0) return -1;
1190
+ if (bIdx >= 0) return 1;
1191
+ return a.id.localeCompare(b.id);
1192
+ });
1193
+
1194
+ container.innerHTML = tiers.map(tier => {
1195
+ const isActive = tier.id === state.activeTierId;
1196
+ const icon = TIER_ICONS[tier.id] || 'build';
1197
+ const iconColor = isActive ? '#60A5FA' : '#6B7280';
1198
+ return `
1199
+ <div class="tier-card${isActive ? ' active' : ''}" data-tier-id="${esc(tier.id)}">
1200
+ <span class="material-symbols-outlined tier-card-icon" style="color:${iconColor};">${icon}</span>
1201
+ <span class="tier-card-name">${esc(tier.name || tier.id)}</span>
1202
+ ${isActive ? '<div class="status-dot status-dot--green"></div>' : ''}
1203
+ </div>
1204
+ `;
1205
+ }).join('');
1206
+ }
1207
+
1208
+ // A2A-48: Renders tool toggle cards (replaces checkboxes). Each tool is a
1209
+ // glass-panel card with icon, name, description, and a toggle switch.
1210
+ // Toggle change triggers autoSaveTier() for immediate persistence.
1211
+ function renderToolToggles(allowedTools) {
1212
+ const container = document.getElementById('tool-toggles');
1213
+ if (!container) return;
1124
1214
  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>`;
1215
+ const checked = (allowedTools || []).includes(tool);
1216
+ const iconInfo = TOOL_ICON_MAP[tool] || { icon: 'extension', bg: 'rgba(100,116,139,0.2)', color: '#94A3B8', border: 'rgba(100,116,139,0.2)' };
1217
+ return `
1218
+ <div class="tool-toggle-card${checked ? ' enabled' : ''}">
1219
+ <div class="tool-toggle-info">
1220
+ <div class="tool-icon" style="background:${iconInfo.bg};color:${iconInfo.color};border:1px solid ${iconInfo.border};">
1221
+ <span class="material-symbols-outlined">${iconInfo.icon}</span>
1222
+ </div>
1223
+ <div>
1224
+ <h3>${esc(tool)}</h3>
1225
+ <p>${esc(desc)}</p>
1226
+ </div>
1227
+ </div>
1228
+ <label class="toggle-switch">
1229
+ <input type="checkbox" data-tool="${esc(tool)}" ${checked ? 'checked' : ''}>
1230
+ <span class="slider"></span>
1231
+ </label>
1232
+ </div>
1233
+ `;
1127
1234
  }).join('');
1128
1235
  }
1129
1236
 
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');
1237
+ // A2A-48: Renders active topics in the drop zone as teal-accented cards.
1238
+ // Each card has a close button for removal. Updates #topic-count badge.
1239
+ function renderActiveTopics(tier) {
1240
+ const container = document.getElementById('active-topics-zone');
1241
+ if (!container) return;
1135
1242
  const manifestTopics = tier.manifest?.topics || [];
1136
1243
  const flatTopics = tier.topics || [];
1137
1244
 
1138
- // A2A-41: prefer manifest data (has descriptions), fall back to flat array
1139
1245
  const allTopics = manifestTopics.length > 0
1140
1246
  ? manifestTopics.map(t => ({ label: t.topic, desc: t.description || '' }))
1141
1247
  : flatTopics.map(t => ({ label: t, desc: '' }));
1142
1248
 
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>
1249
+ const cardsHtml = allTopics.map(t => `
1250
+ <div class="active-item-card active-item-card--teal" data-topic="${esc(t.label)}" data-description="${esc(t.desc)}">
1251
+ <div>
1252
+ <div class="item-name">${esc(t.label)}</div>
1253
+ <div class="item-type-label">Topic</div>
1156
1254
  </div>
1255
+ <button class="item-close-btn" data-remove-topic="${esc(t.label)}">
1256
+ <span class="material-symbols-outlined" style="font-size:16px;">close</span>
1257
+ </button>
1157
1258
  </div>
1158
1259
  `).join('');
1159
1260
 
1160
- container.innerHTML = rowsHtml + `<button class="add-item-btn" data-type="topic">+ Add topic</button>`;
1261
+ container.innerHTML = cardsHtml + '<div class="drop-placeholder"><span>+ Drop Topic</span></div>';
1262
+
1263
+ const badge = document.getElementById('topic-count');
1264
+ if (badge) badge.textContent = `${allTopics.length} Active`;
1161
1265
  }
1162
1266
 
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');
1267
+ // A2A-48: Renders active goals in the drop zone as yellow-accented cards.
1268
+ // Same pattern as topics but with yellow color variant.
1269
+ function renderActiveGoals(tier) {
1270
+ const container = document.getElementById('active-goals-zone');
1271
+ if (!container) return;
1167
1272
  const manifestGoals = tier.manifest?.objectives || [];
1168
1273
  const flatGoals = tier.goals || [];
1169
1274
 
@@ -1171,302 +1276,290 @@ function renderGoalList(tier) {
1171
1276
  ? manifestGoals.map(g => ({ label: g.objective || g.topic, desc: g.description || '' }))
1172
1277
  : flatGoals.map(g => ({ label: g, desc: '' }));
1173
1278
 
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>
1279
+ const cardsHtml = allGoals.map(g => `
1280
+ <div class="active-item-card active-item-card--yellow" data-topic="${esc(g.label)}" data-description="${esc(g.desc)}">
1281
+ <div>
1282
+ <div class="item-name">${esc(g.label)}</div>
1283
+ <div class="item-type-label">Goal</div>
1187
1284
  </div>
1285
+ <button class="item-close-btn" data-remove-goal="${esc(g.label)}">
1286
+ <span class="material-symbols-outlined" style="font-size:16px;">close</span>
1287
+ </button>
1188
1288
  </div>
1189
1289
  `).join('');
1190
1290
 
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
- }
1291
+ container.innerHTML = cardsHtml + '<div class="drop-placeholder"><span>+ Drop Goal</span></div>';
1215
1292
 
1216
- // Delete
1217
- const deleteBtn = e.target.closest('.topic-delete-btn');
1218
- if (deleteBtn) {
1219
- deleteBtn.closest('.topic-row').remove();
1220
- return;
1221
- }
1222
-
1223
- // Add new item
1224
- const addBtn = e.target.closest('.add-item-btn');
1225
- if (addBtn) {
1226
- const type = addBtn.dataset.type;
1227
- const label = type === 'topic' ? 'Topic name' : 'Goal name';
1228
- const newRow = document.createElement('div');
1229
- newRow.className = 'topic-row';
1230
- newRow.dataset.type = type;
1231
- newRow.innerHTML = `
1232
- <span class="drag-handle">\u2807</span>
1233
- <div class="topic-content">
1234
- <sl-input class="new-item-label" size="small" placeholder="${label}" autofocus></sl-input>
1235
- <sl-input class="new-item-desc" size="small" placeholder="Description (optional)"></sl-input>
1236
- <div class="row" style="margin-top:0.3rem;">
1237
- <sl-button size="small" variant="primary" class="confirm-add-btn">Add</sl-button>
1238
- <sl-button size="small" class="cancel-add-btn">Cancel</sl-button>
1239
- </div>
1240
- </div>
1241
- `;
1242
- container.insertBefore(newRow, addBtn);
1243
- return;
1244
- }
1245
-
1246
- // Confirm add
1247
- const confirmBtn = e.target.closest('.confirm-add-btn');
1248
- if (confirmBtn) {
1249
- const row = confirmBtn.closest('.topic-row');
1250
- const nameInput = row.querySelector('.new-item-label');
1251
- const descInput = row.querySelector('.new-item-desc');
1252
- const name = nameInput.value.trim();
1253
- if (!name) { nameInput.focus(); return; }
1254
-
1255
- row.dataset.topic = name;
1256
- row.innerHTML = `
1257
- <span class="drag-handle">\u2807</span>
1258
- <div class="topic-content">
1259
- <div class="topic-header">
1260
- <strong class="topic-label">${esc(name)}</strong>
1261
- <sl-icon-button name="chevron-down" class="topic-expand-btn" label="Expand"></sl-icon-button>
1262
- <sl-icon-button name="trash" class="topic-delete-btn" label="Delete"></sl-icon-button>
1263
- </div>
1264
- <div class="topic-description" style="display:none;">
1265
- <p class="topic-desc-text">${esc(descInput.value)}</p>
1266
- <sl-input class="topic-desc-edit" size="small" placeholder="Add description..." value="${esc(descInput.value)}"></sl-input>
1267
- </div>
1268
- </div>
1269
- `;
1270
- return;
1271
- }
1272
-
1273
- // Cancel add
1274
- const cancelBtn = e.target.closest('.cancel-add-btn');
1275
- if (cancelBtn) {
1276
- cancelBtn.closest('.topic-row').remove();
1277
- return;
1278
- }
1279
- });
1280
-
1281
- // Description edit via sl-change (Shoelace event, delegated)
1282
- container.addEventListener('sl-change', (e) => {
1283
- const input = e.target.closest('.topic-desc-edit');
1284
- if (input) {
1285
- const row = input.closest('.topic-row');
1286
- const textEl = row.querySelector('.topic-desc-text');
1287
- if (textEl) textEl.textContent = input.value || '';
1288
- }
1289
- });
1290
- });
1293
+ const badge = document.getElementById('goal-count');
1294
+ if (badge) badge.textContent = `${allGoals.length} Active`;
1291
1295
  }
1292
1296
 
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);
1297
+ // A2A-48: Orchestrator that renders the entire permissions panel.
1298
+ // Uses state.activeTierId instead of reading a dropdown value.
1299
+ function renderPermissions() {
1300
+ const tier = (state.settings?.tiers || []).find(t => t.id === state.activeTierId);
1322
1301
  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);
1302
+ renderTierCards();
1303
+ renderActiveTopics(tier);
1304
+ renderActiveGoals(tier);
1305
+ renderToolToggles(tier.allowed_tools);
1329
1306
  renderTierWarnings(tier);
1330
- renderTierColumns();
1307
+ renderSidebarPreview(state.activeTierId);
1308
+ renderSidebarLists(tier);
1309
+ bindSidebarDrag();
1331
1310
  }
1332
1311
 
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');
1312
+ // A2A-48: Renders the inline "Preview as Caller" card in the right sidebar.
1313
+ // Reuses getPreviewData() to show merged topics, goals, and tool count.
1314
+ function renderSidebarPreview(tierId) {
1315
+ const container = document.getElementById('perm-preview');
1339
1316
  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
- });
1317
+ const data = getPreviewData(tierId);
1318
+ const tierName = (state.settings?.tiers || []).find(t => t.id === tierId)?.name || tierId;
1319
+ const topicNames = data.topics.map(t => t.topic).filter(Boolean);
1320
+ const goalNames = data.objectives.map(g => g.objective || g.topic).filter(Boolean);
1321
+ const toolCount = data.tools.size;
1322
+
1323
+ const topicText = topicNames.length > 0 ? `<strong style="color:#2DD4BF;">${esc(topicNames.join(', '))}</strong>` : '<em>no topics</em>';
1324
+ const goalText = goalNames.length > 0 ? `<strong style="color:#FBBF24;">${esc(goalNames.join(', '))}</strong>` : '<em>no goals</em>';
1325
+
1326
+ container.innerHTML = `
1327
+ <div class="preview-card-inner">
1328
+ <div class="sidebar-list-header">Preview as Caller</div>
1329
+ <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;">
1330
+ <span class="material-symbols-outlined" style="color:#60A5FA;">smart_toy</span>
1331
+ <div>
1332
+ <div style="font-weight:600;font-size:0.85rem;color:var(--ink);">Agent Permission</div>
1333
+ <span class="preview-tier-badge">${esc(tierName)} Tier</span>
1334
+ </div>
1335
+ </div>
1336
+ <div class="preview-summary">
1337
+ This agent can discuss ${topicText} to help ${goalText} using <strong>${toolCount} tool${toolCount !== 1 ? 's' : ''}</strong>.
1338
+ </div>
1339
+ <div class="preview-footer">
1340
+ <span style="display:flex;align-items:center;gap:0.3rem;">
1341
+ <span class="status-dot status-dot--green"></span> Active
1342
+ </span>
1343
+ <span style="font-family:monospace;opacity:0.7;">JSON Valid</span>
1344
+ </div>
1345
+ </div>
1346
+ `;
1347
+ }
1348
+
1349
+ // A2A-48: Renders all topics and goals in the right sidebar as draggable items.
1350
+ // Items active in the current tier are dimmed with an "(Active)" label.
1351
+ function renderSidebarLists(tier) {
1352
+ const topicContainer = document.getElementById('sidebar-topics');
1353
+ const goalContainer = document.getElementById('sidebar-goals');
1354
+ if (!topicContainer || !goalContainer) return;
1355
+
1356
+ // Collect ALL topics across ALL tiers for the sidebar
1357
+ const allTiers = state.settings?.tiers || [];
1358
+ const allTopicMap = new Map();
1359
+ const allGoalMap = new Map();
1360
+ for (const t of allTiers) {
1361
+ const mTopics = t.manifest?.topics || [];
1362
+ const fTopics = t.topics || [];
1363
+ const topics = mTopics.length > 0 ? mTopics : fTopics.map(x => ({ topic: x, description: '' }));
1364
+ for (const item of topics) {
1365
+ if (item.topic && !allTopicMap.has(item.topic)) {
1366
+ allTopicMap.set(item.topic, item.description || '');
1367
+ }
1368
+ }
1369
+ const mGoals = t.manifest?.objectives || [];
1370
+ const fGoals = t.goals || [];
1371
+ const goals = mGoals.length > 0 ? mGoals : fGoals.map(x => ({ topic: x, description: '' }));
1372
+ for (const item of goals) {
1373
+ const label = item.objective || item.topic;
1374
+ if (label && !allGoalMap.has(label)) {
1375
+ allGoalMap.set(label, item.description || '');
1376
+ }
1370
1377
  }
1378
+ }
1371
1379
 
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>
1380
+ // Determine which are active in the current tier
1381
+ const activeTopicSet = new Set((tier.topics || []).concat(
1382
+ (tier.manifest?.topics || []).map(t => t.topic)
1383
+ ).filter(Boolean));
1384
+ const activeGoalSet = new Set((tier.goals || []).concat(
1385
+ (tier.manifest?.objectives || []).map(g => g.objective || g.topic)
1386
+ ).filter(Boolean));
1387
+
1388
+ // Render topics sidebar
1389
+ const topicItems = Array.from(allTopicMap.entries()).map(([name, desc]) => {
1390
+ const isActive = activeTopicSet.has(name);
1391
+ return `
1392
+ <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">
1393
+ <div style="display:flex;align-items:center;gap:0.4rem;">
1394
+ ${isActive ? '' : '<span class="material-symbols-outlined" style="color:#4B5563;font-size:1rem;cursor:grab;">drag_indicator</span>'}
1395
+ <span class="sidebar-item-name">${esc(name)}${isActive ? ' <span class="sidebar-item-active-label">(Active)</span>' : ''}</span>
1383
1396
  </div>
1384
1397
  </div>
1385
- `).join('');
1398
+ `;
1399
+ }).join('');
1400
+
1401
+ topicContainer.innerHTML = `
1402
+ <div class="sidebar-list-header">Topics</div>
1403
+ ${topicItems}
1404
+ <button class="sidebar-add-btn" data-add-type="topic">
1405
+ <span class="material-symbols-outlined" style="font-size:14px;">add</span> Add Topic
1406
+ </button>
1407
+ `;
1386
1408
 
1409
+ // Render goals sidebar
1410
+ const goalItems = Array.from(allGoalMap.entries()).map(([name, desc]) => {
1411
+ const isActive = activeGoalSet.has(name);
1387
1412
  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}
1413
+ <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">
1414
+ <div style="display:flex;align-items:center;gap:0.4rem;">
1415
+ ${isActive ? '' : '<span class="material-symbols-outlined" style="color:#4B5563;font-size:1rem;cursor:grab;">drag_indicator</span>'}
1416
+ <span class="sidebar-item-name">${esc(name)}${isActive ? ' <span class="sidebar-item-active-label">(Active)</span>' : ''}</span>
1392
1417
  </div>
1393
- </div>`;
1418
+ </div>
1419
+ `;
1394
1420
  }).join('');
1395
1421
 
1396
- container.innerHTML = html;
1397
- bindDragEvents();
1422
+ goalContainer.innerHTML = `
1423
+ <div class="sidebar-list-header">Goals &amp; Objectives</div>
1424
+ ${goalItems}
1425
+ <button class="sidebar-add-btn" data-add-type="goal">
1426
+ <span class="material-symbols-outlined" style="font-size:14px;">add</span> Add Goal
1427
+ </button>
1428
+ `;
1398
1429
  }
1399
1430
 
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');
1431
+ // A2A-48: Debounced auto-save replaces explicit Save Tier button.
1432
+ // 250ms delay prevents excessive API calls during rapid changes.
1433
+ let _autoSaveTimer = null;
1434
+ function autoSaveTier() {
1435
+ clearTimeout(_autoSaveTimer);
1436
+ _autoSaveTimer = setTimeout(async () => {
1437
+ const tierId = state.activeTierId;
1438
+ if (!tierId) return;
1439
+
1440
+ // Collect tools from toggle states
1441
+ const toggles = document.querySelectorAll('#tool-toggles .toggle-switch input');
1442
+ const allowed_tools = Array.from(toggles).filter(t => t.checked).map(t => t.dataset.tool);
1443
+
1444
+ // Collect topics from active zone
1445
+ const topicCards = document.querySelectorAll('#active-topics-zone .active-item-card');
1446
+ // A2A-48: uses dataset.topic for BOTH topics and goals (NOT dataset.objective)
1447
+ // because parseTopicObjects() in dashboard.js:160 only reads entry.topic.
1448
+ // The semantic distinction (objective vs topic) is UI-only; storage layer
1449
+ // uses {topic, description} uniformly for both types.
1450
+ const topics = Array.from(topicCards).map(c => c.dataset.topic).filter(Boolean);
1451
+ const manifestTopics = Array.from(topicCards).map(c => ({
1452
+ topic: c.dataset.topic,
1453
+ description: c.dataset.description || ''
1454
+ })).filter(t => t.topic);
1455
+
1456
+ // Collect goals from active zone
1457
+ const goalCards = document.querySelectorAll('#active-goals-zone .active-item-card');
1458
+ const goals = Array.from(goalCards).map(c => c.dataset.topic).filter(Boolean);
1459
+ const manifestObjectives = Array.from(goalCards).map(c => ({
1460
+ topic: c.dataset.topic,
1461
+ description: c.dataset.description || ''
1462
+ })).filter(g => g.topic);
1463
+
1464
+ const body = { allowed_tools, topics, goals, manifest: { topics: manifestTopics, objectives: manifestObjectives } };
1465
+ // A2A-48: Refresh state inside try block so that a failed PUT does not
1466
+ // trigger an unhandled rejection from the subsequent GET.
1467
+ try {
1468
+ await request(`/settings/tiers/${encodeURIComponent(tierId)}`, {
1469
+ method: 'PUT', body: JSON.stringify(body)
1470
+ });
1471
+ showNotice('Saved');
1472
+ // Refresh state from server to stay in sync after auto-save
1473
+ const payload = await request('/settings');
1474
+ state.settings = payload;
1475
+ } catch (err) {
1476
+ showNotice(`Save failed: ${err.message}`);
1477
+ }
1478
+ }, 250);
1479
+ }
1405
1480
 
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');
1481
+ // A2A-48: Binds dragstart/dragend on sidebar items (re-created each render).
1482
+ // Zone listeners (dragover/dragleave/drop) are bound ONCE in
1483
+ // bindPermissionsActions() to avoid listener accumulation — the zone
1484
+ // containers persist across renders while only their innerHTML changes.
1485
+ function bindSidebarDrag() {
1486
+ document.querySelectorAll('.sidebar-item[draggable="true"]').forEach(item => {
1487
+ item.addEventListener('dragstart', (e) => {
1488
+ const itemType = item.dataset.itemType || 'topic';
1489
+ const name = item.dataset.sidebarTopic || item.dataset.sidebarGoal || '';
1490
+ const desc = item.dataset.description || '';
1491
+ e.dataTransfer.setData('application/json', JSON.stringify({ name, description: desc, type: itemType }));
1492
+ item.style.opacity = '0.5';
1413
1493
  });
1414
- row.addEventListener('dragend', () => row.classList.remove('dragging'));
1494
+ item.addEventListener('dragend', () => { item.style.opacity = ''; });
1415
1495
  });
1496
+ }
1416
1497
 
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');
1498
+ // A2A-48: Drop handler for active topic/goal zones. Extracted from
1499
+ // bindSidebarDrag() to be called once in bindPermissionsActions().
1500
+ function handleZoneDrop(zone, e) {
1501
+ e.preventDefault();
1502
+ zone.classList.remove('drag-over');
1503
+ let data;
1504
+ try { data = JSON.parse(e.dataTransfer.getData('application/json')); } catch { return; }
1505
+ if (!data.name) return;
1506
+
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';
1511
+
1512
+ // Check if already in zone
1513
+ const existing = zone.querySelectorAll('.active-item-card');
1514
+ for (const card of existing) {
1515
+ if (card.dataset.topic === data.name) return; // already active
1516
+ }
1426
1517
 
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;
1518
+ // Insert before the placeholder
1519
+ const placeholder = zone.querySelector('.drop-placeholder');
1520
+ const card = document.createElement('div');
1521
+ card.className = `active-item-card ${accentClass}`;
1522
+ card.dataset.topic = data.name;
1523
+ card.dataset.description = data.description || '';
1524
+ card.innerHTML = `
1525
+ <div>
1526
+ <div class="item-name">${esc(data.name)}</div>
1527
+ <div class="item-type-label">${typeLabel}</div>
1528
+ </div>
1529
+ <button class="item-close-btn" ${removeAttr}="${esc(data.name)}">
1530
+ <span class="material-symbols-outlined" style="font-size:16px;">close</span>
1531
+ </button>
1532
+ `;
1533
+ zone.insertBefore(card, placeholder);
1534
+ autoSaveTier();
1431
1535
 
1432
- if (!topic || !sourceTier || !targetTier || sourceTier === targetTier) return;
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);
1542
+ }
1433
1543
 
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;
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.
1547
+ function populateInviteTierSelect() {
1548
+ const tiers = (state.settings?.tiers || []).slice().sort((a, b) => a.id.localeCompare(b.id));
1549
+ const newTierCopy = document.getElementById('new-tier-copy-from');
1550
+ const inviteTier = document.getElementById('invite-tier');
1437
1551
 
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: '' }];
1552
+ const optionsHtml = tiers.map(tier => {
1553
+ const emoji = TIER_EMOJIS[tier.id] || '\u{1F527}';
1554
+ return `<sl-option value="${esc(tier.id)}">${emoji} ${esc(tier.name || tier.id)}</sl-option>`;
1555
+ }).join('');
1443
1556
 
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
- });
1557
+ if (inviteTier) inviteTier.innerHTML = optionsHtml;
1558
+ if (newTierCopy) newTierCopy.innerHTML = `<sl-option value="">None</sl-option>${optionsHtml}`;
1559
+
1560
+ // A2A-48: Default invite tier to 'public'
1561
+ const defaultTier = tiers.find(t => t.id === 'public') ? 'public' : tiers[0]?.id;
1562
+ if (defaultTier && inviteTier) inviteTier.value = defaultTier;
1470
1563
  }
1471
1564
 
1472
1565
  // A2A-41: contextual validation warnings for the currently selected tier.
@@ -1543,10 +1636,10 @@ function getPreviewData(tierId) {
1543
1636
  return merged;
1544
1637
  }
1545
1638
 
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.
1639
+ // A2A-48: opens the caller preview dialog showing the merged effective view
1640
+ // for the selected tier. Uses state.activeTierId instead of removed #tier-select.
1548
1641
  function openCallerPreview() {
1549
- const tierId = document.getElementById('tier-select').value;
1642
+ const tierId = state.activeTierId;
1550
1643
  const data = getPreviewData(tierId);
1551
1644
  const emoji = TIER_EMOJIS[tierId] || '\u{1F527}';
1552
1645
  const tierName = (state.settings?.tiers || []).find(t => t.id === tierId)?.name || tierId;
@@ -1590,72 +1683,147 @@ function openCallerPreview() {
1590
1683
  dialog.show();
1591
1684
  }
1592
1685
 
1593
- function bindSettingsActions() {
1594
- document.getElementById('tier-select').addEventListener('sl-change', (e) => {
1595
- renderTierEditor(e.target.value);
1596
- });
1686
+ // A2A-48: Binds all event handlers for the permissions panel. Replaces old
1687
+ // bindSettingsActions() — removes handlers for deleted elements (tier-form,
1688
+ // tier-select, copy-tier-btn, show-drag-columns, preview-caller-btn) and
1689
+ // adds handlers for tier cards, tool toggles, close buttons, and sidebar.
1690
+ function bindPermissionsActions() {
1691
+ const panel = document.getElementById('panel-permissions');
1692
+ if (!panel) return;
1693
+
1694
+ // A2A-48: Tier card click — switch active tier and re-render.
1695
+ // No autoSaveTier() here: switching tiers is a read operation, not a write.
1696
+ panel.addEventListener('click', (e) => {
1697
+ const card = e.target.closest('.tier-card[data-tier-id]');
1698
+ if (card) {
1699
+ state.activeTierId = card.dataset.tierId;
1700
+ renderPermissions();
1701
+ return;
1702
+ }
1597
1703
 
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);
1704
+ // A2A-48: Close button on active topic cards
1705
+ const removeTopic = e.target.closest('[data-remove-topic]');
1706
+ if (removeTopic) {
1707
+ const card = removeTopic.closest('.active-item-card');
1708
+ if (card) card.remove();
1709
+ autoSaveTier();
1710
+ return;
1711
+ }
1615
1712
 
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);
1713
+ // A2A-48: Close button on active goal cards
1714
+ const removeGoal = e.target.closest('[data-remove-goal]');
1715
+ if (removeGoal) {
1716
+ const card = removeGoal.closest('.active-item-card');
1717
+ if (card) card.remove();
1718
+ autoSaveTier();
1719
+ return;
1720
+ }
1626
1721
 
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
1722
+ // A2A-48: "+ New Tier" button scrolls to the new-tier form inside Settings details
1723
+ const newTierBtn = e.target.closest('#perm-new-tier-btn');
1724
+ 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);
1731
+ return;
1732
+ }
1733
+
1734
+ // A2A-48: Sidebar "Add Topic" / "Add Goal" buttons open create dialog
1735
+ const addBtn = e.target.closest('.sidebar-add-btn[data-add-type]');
1736
+ if (addBtn) {
1737
+ const type = addBtn.dataset.addType;
1738
+ const dialog = document.getElementById('create-item-dialog');
1739
+ if (dialog) {
1740
+ dialog.label = `Create New ${type === 'topic' ? 'Topic' : 'Goal'}`;
1741
+ dialog.dataset.createType = type;
1742
+ const titleInput = document.getElementById('create-item-title');
1743
+ const descInput = document.getElementById('create-item-desc');
1744
+ if (titleInput) titleInput.value = '';
1745
+ if (descInput) descInput.value = '';
1746
+ dialog.show();
1636
1747
  }
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();
1748
+ return;
1749
+ }
1644
1750
  });
1645
1751
 
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);
1752
+ // A2A-48: Drop zone listeners — bound ONCE here because the zone containers
1753
+ // (#active-topics-zone, #active-goals-zone) persist across renders. Only
1754
+ // their innerHTML is replaced by renderActiveTopics/renderActiveGoals.
1755
+ // Binding in bindSidebarDrag() would cause listener accumulation.
1756
+ const topicZone = document.getElementById('active-topics-zone');
1757
+ const goalZone = document.getElementById('active-goals-zone');
1758
+ [topicZone, goalZone].forEach(zone => {
1759
+ 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));
1763
+ });
1764
+
1765
+ // A2A-48: Tool toggle change — auto-save and update card styling
1766
+ panel.addEventListener('change', (e) => {
1767
+ const toggle = e.target.closest('#tool-toggles .toggle-switch input');
1768
+ if (toggle) {
1769
+ const card = toggle.closest('.tool-toggle-card');
1770
+ if (card) {
1771
+ card.classList.toggle('enabled', toggle.checked);
1772
+ }
1773
+ autoSaveTier();
1774
+ return;
1775
+ }
1656
1776
  });
1657
1777
 
1658
- document.getElementById('defaults-form').addEventListener('submit', async (e) => {
1778
+ // A2A-48: Create Item dialog — submit handler
1779
+ document.getElementById('create-item-submit')?.addEventListener('click', () => {
1780
+ const dialog = document.getElementById('create-item-dialog');
1781
+ const titleInput = document.getElementById('create-item-title');
1782
+ const descInput = document.getElementById('create-item-desc');
1783
+ if (!dialog || !titleInput) return;
1784
+
1785
+ const title = titleInput.value.trim();
1786
+ if (!title) { titleInput.focus(); return; }
1787
+ const desc = descInput?.value?.trim() || '';
1788
+ const type = dialog.dataset.createType || 'topic';
1789
+
1790
+ // A2A-48: Add item to the appropriate active zone
1791
+ const zoneId = type === 'topic' ? 'active-topics-zone' : 'active-goals-zone';
1792
+ const zone = document.getElementById(zoneId);
1793
+ if (!zone) return;
1794
+
1795
+ const accentClass = type === 'topic' ? 'active-item-card--teal' : 'active-item-card--yellow';
1796
+ const typeLabel = type === 'topic' ? 'Topic' : 'Goal';
1797
+ const removeAttr = type === 'topic' ? 'data-remove-topic' : 'data-remove-goal';
1798
+
1799
+ 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
+ if (placeholder) zone.insertBefore(card, placeholder);
1814
+ else zone.appendChild(card);
1815
+
1816
+ dialog.hide();
1817
+ autoSaveTier();
1818
+ });
1819
+
1820
+ // A2A-48: Create Item dialog — cancel handler
1821
+ document.getElementById('create-item-cancel')?.addEventListener('click', () => {
1822
+ document.getElementById('create-item-dialog')?.hide();
1823
+ });
1824
+
1825
+ // Defaults form — unchanged from A2A-41
1826
+ document.getElementById('defaults-form')?.addEventListener('submit', async (e) => {
1659
1827
  e.preventDefault();
1660
1828
  await request('/settings/defaults', {
1661
1829
  method: 'PUT',
@@ -1668,11 +1836,8 @@ function bindSettingsActions() {
1668
1836
  await loadSettings();
1669
1837
  });
1670
1838
 
1671
- document.getElementById('new-tier-btn').addEventListener('click', () => {
1672
- document.getElementById('new-tier-id').focus();
1673
- });
1674
-
1675
- document.getElementById('new-tier-form').addEventListener('submit', async (e) => {
1839
+ // A2A-48: New Tier form — uses state.activeTierId instead of removed #tier-select
1840
+ document.getElementById('new-tier-form')?.addEventListener('submit', async (e) => {
1676
1841
  e.preventDefault();
1677
1842
  const tierId = document.getElementById('new-tier-id').value.trim();
1678
1843
  const name = document.getElementById('new-tier-name').value.trim();
@@ -1689,26 +1854,24 @@ function bindSettingsActions() {
1689
1854
  showNotice(`Created tier "${tierId}"`);
1690
1855
  document.getElementById('new-tier-form').reset();
1691
1856
  await loadSettings();
1692
- document.getElementById('tier-select').value = tierId;
1693
- renderTierEditor(tierId);
1694
- });
1695
-
1696
- // A2A-41: toggle for three-column tier view
1697
- document.getElementById('show-drag-columns')?.addEventListener('sl-change', () => {
1698
- renderTierColumns();
1857
+ // A2A-48: Switch to the newly created tier (replaces old tier-select.value = tierId)
1858
+ state.activeTierId = tierId;
1859
+ renderPermissions();
1699
1860
  });
1700
1861
 
1701
- document.getElementById('preview-caller-btn')?.addEventListener('click', openCallerPreview);
1862
+ // Preview dialog close — unchanged
1702
1863
  document.getElementById('preview-close-btn')?.addEventListener('click', () => {
1703
1864
  document.getElementById('preview-dialog').hide();
1704
1865
  });
1705
1866
  }
1706
1867
 
1868
+ // A2A-48: Load settings and render permissions. Replaces fillTierSelects() and
1869
+ // renderTierColumns() calls with populateInviteTierSelect() + renderPermissions().
1707
1870
  async function loadSettings() {
1708
1871
  const payload = await request('/settings');
1709
1872
  state.settings = payload;
1710
- fillTierSelects();
1711
- renderTierColumns();
1873
+ populateInviteTierSelect();
1874
+ renderPermissions();
1712
1875
  document.getElementById('defaults-expiration').value = payload.defaults?.expiration || '7d';
1713
1876
  document.getElementById('defaults-max-calls').value = payload.defaults?.maxCalls || 100;
1714
1877
  }
@@ -2066,19 +2229,18 @@ function bindLogFilterRefresh() {
2066
2229
 
2067
2230
  let pollTimer = null;
2068
2231
 
2232
+ // A2A-47: Simply return tracked state instead of querying sl-tab-group
2069
2233
  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';
2234
+ return state.activeTab || 'contacts';
2075
2235
  }
2076
2236
 
2077
2237
  const tabLoaders = {
2078
2238
  contacts: loadContacts,
2079
2239
  calls: loadCalls,
2080
2240
  logs: () => { loadLogs(); loadLogStats(); },
2081
- permissions: () => {},
2241
+ // A2A-48: Load fresh settings data when switching to Permissions tab.
2242
+ // Previously a no-op — now ensures data is current on tab switch.
2243
+ permissions: loadSettings,
2082
2244
  invites: loadInvites,
2083
2245
  health: loadHealth,
2084
2246
  };
@@ -2202,8 +2364,10 @@ function renderHealthHistory(history) {
2202
2364
  async function bootstrap() {
2203
2365
  bindTabs();
2204
2366
  bindContactsActions();
2205
- bindSettingsActions();
2206
- bindItemListDelegation();
2367
+ // A2A-48: bindPermissionsActions() replaces old bindSettingsActions() +
2368
+ // bindItemListDelegation(). All tier/tool/topic/goal handlers are now
2369
+ // inside bindPermissionsActions() using event delegation on #panel-permissions.
2370
+ bindPermissionsActions();
2207
2371
  bindCallbookActions();
2208
2372
  bindAutoUpdateActions();
2209
2373
  bindInviteActions();