personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add submenu system for app quick-links in menu bar

Add AppServices.submenus API for apps to define contextual quick-links
that appear as hover pop-outs on menu bar icons. Submenus support icons,
labels, hrefs, facet auto-selection, and dynamic badges.

Key changes:
- Add AppServices.submenus with set/upsert/remove/clear/get methods
- Render submenus to body (escapes overflow:hidden constraints)
- Position via getBoundingClientRect on hover
- Show in collapsed and expanded icon states (not full sidebar)
- Add dev app background.html as test/reference implementation
- Document API in APPS.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+323 -1
+13 -1
APPS.md
··· 212 212 } 213 213 ``` 214 214 215 + **Submenu Methods:** 216 + - `AppServices.submenus.set(appName, items)` - Set all submenu items 217 + - `AppServices.submenus.upsert(appName, item)` - Add or update single item 218 + - `AppServices.submenus.remove(appName, itemId)` - Remove item by id 219 + - `AppServices.submenus.clear(appName)` - Clear all items 220 + 221 + Submenus appear as hover pop-outs on menu bar icons. Items support `id`, `label`, `icon`, `href`, `facet`, `badge`, and `order` properties. 222 + 223 + **See implementation:** `convey/static/app.js` - Submenu rendering and positioning 224 + 215 225 **WebSocket Events (`window.appEvents`):** 216 226 - `listen(tract, callback)` - Listen to specific tract ('cortex', 'indexer', 'observe', etc.) 217 227 - `listen('*', callback)` - Listen to all events 218 228 - Messages have structure: `{tract: 'cortex', event: 'agent_complete', ...data}` 219 229 - See **CALLOSUM.md** for event protocol details 220 230 221 - **Reference implementation:** `apps/home/background.html` - Shows WebSocket event listening, notification handling, and service pattern 231 + **Reference implementations:** 232 + - `apps/home/background.html` - WebSocket event listening, notification handling 233 + - `apps/dev/background.html` - Submenu quick-links with dynamic badges 222 234 223 235 **Implementation source:** `convey/static/app.js` - AppServices framework, `convey/static/websocket.js` - WebSocket API 224 236
+48
apps/dev/background.html
··· 1 + // Dev App Background Service 2 + // Scratch playground for testing submenu features 3 + 4 + AppServices.register('dev', { 5 + initialize() { 6 + // Test submenu with various item types 7 + AppServices.submenus.set('dev', [ 8 + { 9 + id: 'workspace', 10 + label: 'Workspace', 11 + icon: '🏠', 12 + href: '/app/dev' 13 + }, 14 + { 15 + id: 'notifications', 16 + label: 'Test Notifications', 17 + icon: '🔔', 18 + href: '/app/dev#notifications', 19 + badge: 3 20 + }, 21 + { 22 + id: 'websocket', 23 + label: 'WebSocket Events', 24 + icon: '📡', 25 + href: '/app/dev#websocket' 26 + }, 27 + { 28 + id: 'facet-test', 29 + label: 'With Facet', 30 + icon: '🎯', 31 + href: '/app/dev#facet', 32 + facet: 'work' 33 + } 34 + ]); 35 + 36 + // Demo: Update badge dynamically every 5 seconds 37 + let badgeCount = 3; 38 + setInterval(() => { 39 + badgeCount = (badgeCount % 10) + 1; 40 + AppServices.submenus.upsert('dev', { 41 + id: 'notifications', 42 + badge: badgeCount 43 + }); 44 + }, 5000); 45 + 46 + console.log('[Dev] Background service initialized with test submenu'); 47 + } 48 + });
+74
convey/static/app.css
··· 387 387 margin-bottom: 8px; 388 388 } 389 389 390 + /* Menu Submenus - hover pop-outs for app quick-links */ 391 + .menu-submenu { 392 + position: fixed; 393 + min-width: 160px; 394 + background: white; 395 + border: 1px solid var(--facet-border, #e0e0e0); 396 + border-radius: 8px; 397 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 398 + padding: 4px 0; 399 + opacity: 0; 400 + pointer-events: none; 401 + transform: translateX(-8px); 402 + transition: opacity 0.15s ease, transform 0.15s ease; 403 + z-index: calc(var(--z-bars) + 10); 404 + } 405 + 406 + /* Show when visible class is added via JS */ 407 + .menu-submenu.visible { 408 + opacity: 1; 409 + pointer-events: auto; 410 + transform: translateX(0); 411 + } 412 + 413 + .menu-submenu-item { 414 + display: flex; 415 + align-items: center; 416 + padding: 8px 12px; 417 + text-decoration: none; 418 + color: inherit; 419 + font-size: 13px; 420 + transition: background 0.15s ease; 421 + gap: 8px; 422 + white-space: nowrap; 423 + } 424 + 425 + .menu-submenu-item:hover { 426 + background: rgba(0, 0, 0, 0.05); 427 + } 428 + 429 + .menu-submenu-item:first-child { 430 + border-radius: 7px 7px 0 0; 431 + } 432 + 433 + .menu-submenu-item:last-child { 434 + border-radius: 0 0 7px 7px; 435 + } 436 + 437 + .menu-submenu-item:only-child { 438 + border-radius: 7px; 439 + } 440 + 441 + .submenu-icon { 442 + font-size: 14px; 443 + width: 20px; 444 + text-align: center; 445 + flex-shrink: 0; 446 + } 447 + 448 + .submenu-label { 449 + flex: 1; 450 + } 451 + 452 + .submenu-badge { 453 + background: var(--facet-color, #667eea); 454 + color: white; 455 + font-size: 10px; 456 + font-weight: 600; 457 + padding: 2px 6px; 458 + border-radius: 8px; 459 + min-width: 18px; 460 + text-align: center; 461 + line-height: 1.2; 462 + } 463 + 390 464 /* Menu expander (down arrow below last visible app icon) */ 391 465 .menu-bar .menu-expander { 392 466 padding: 4px 0;
+188
convey/static/app.js
··· 1349 1349 const div = document.createElement('div'); 1350 1350 div.textContent = text; 1351 1351 return div.innerHTML; 1352 + }, 1353 + 1354 + /** 1355 + * Submenu system for app quick-links 1356 + * Allows apps to define contextual links that appear on hover over menu icons 1357 + */ 1358 + submenus: { 1359 + _data: {}, // {appName: [items]} 1360 + 1361 + /** 1362 + * Set entire submenu for an app (replaces existing) 1363 + * @param {string} appName - Name of the app 1364 + * @param {Array} items - Array of submenu items 1365 + */ 1366 + set(appName, items) { 1367 + this._data[appName] = items.map((item, index) => ({ 1368 + ...item, 1369 + order: item.order !== undefined ? item.order : index 1370 + })); 1371 + this._render(appName); 1372 + }, 1373 + 1374 + /** 1375 + * Add or update a single submenu item 1376 + * @param {string} appName - Name of the app 1377 + * @param {object} item - Item to add/update (must have id) 1378 + */ 1379 + upsert(appName, item) { 1380 + if (!this._data[appName]) { 1381 + this._data[appName] = []; 1382 + } 1383 + 1384 + const existing = this._data[appName].find(i => i.id === item.id); 1385 + if (existing) { 1386 + Object.assign(existing, item); 1387 + } else { 1388 + this._data[appName].push({ 1389 + ...item, 1390 + order: item.order !== undefined ? item.order : this._data[appName].length 1391 + }); 1392 + } 1393 + this._render(appName); 1394 + }, 1395 + 1396 + /** 1397 + * Remove a submenu item by id 1398 + * @param {string} appName - Name of the app 1399 + * @param {string} itemId - ID of item to remove 1400 + */ 1401 + remove(appName, itemId) { 1402 + if (!this._data[appName]) return; 1403 + this._data[appName] = this._data[appName].filter(i => i.id !== itemId); 1404 + this._render(appName); 1405 + }, 1406 + 1407 + /** 1408 + * Clear all submenu items for an app 1409 + * @param {string} appName - Name of the app 1410 + */ 1411 + clear(appName) { 1412 + delete this._data[appName]; 1413 + this._render(appName); 1414 + }, 1415 + 1416 + /** 1417 + * Get submenu items for an app 1418 + * @param {string} appName - Name of the app 1419 + * @returns {Array} Array of submenu items 1420 + */ 1421 + get(appName) { 1422 + return this._data[appName] || []; 1423 + }, 1424 + 1425 + /** 1426 + * Render submenu for an app 1427 + * @private 1428 + */ 1429 + _render(appName) { 1430 + // Defer render if DOM not ready 1431 + if (document.readyState === 'loading') { 1432 + const self = this; 1433 + document.addEventListener('DOMContentLoaded', function() { 1434 + self._render(appName); 1435 + }); 1436 + return; 1437 + } 1438 + 1439 + const menuItem = document.querySelector(`.menu-item[data-app-name="${appName}"]`); 1440 + if (!menuItem) return; 1441 + 1442 + // Remove existing submenu (could be in body or menu item) 1443 + const existingId = `menu-submenu-${appName}`; 1444 + const existing = document.getElementById(existingId); 1445 + if (existing) { 1446 + existing.remove(); 1447 + } 1448 + 1449 + // Get items for this app 1450 + const items = this._data[appName]; 1451 + if (!items || items.length === 0) return; 1452 + 1453 + // Sort by order 1454 + const sorted = [...items].sort((a, b) => (a.order || 0) - (b.order || 0)); 1455 + 1456 + // Create submenu container - append to body to escape overflow:hidden 1457 + const submenu = document.createElement('div'); 1458 + submenu.className = 'menu-submenu'; 1459 + submenu.id = existingId; 1460 + submenu.dataset.appName = appName; 1461 + 1462 + // Create items 1463 + sorted.forEach(item => { 1464 + const link = document.createElement('a'); 1465 + link.className = 'menu-submenu-item'; 1466 + link.href = item.href || '#'; 1467 + 1468 + if (item.facet) { 1469 + link.dataset.facet = item.facet; 1470 + } 1471 + 1472 + // Build inner HTML 1473 + let html = ''; 1474 + if (item.icon) { 1475 + html += `<span class="submenu-icon">${item.icon}</span>`; 1476 + } 1477 + html += `<span class="submenu-label">${window.AppServices._escapeHtml(item.label)}</span>`; 1478 + if (item.badge) { 1479 + html += `<span class="submenu-badge">${item.badge}</span>`; 1480 + } 1481 + 1482 + link.innerHTML = html; 1483 + 1484 + // Click handler for facet selection 1485 + if (item.facet) { 1486 + link.addEventListener('click', (e) => { 1487 + if (window.selectFacet) { 1488 + window.selectFacet(item.facet); 1489 + } 1490 + }); 1491 + } 1492 + 1493 + submenu.appendChild(link); 1494 + }); 1495 + 1496 + // Append to body instead of menu item 1497 + document.body.appendChild(submenu); 1498 + 1499 + // Position submenu on hover 1500 + const positionSubmenu = () => { 1501 + const rect = menuItem.getBoundingClientRect(); 1502 + submenu.style.position = 'fixed'; 1503 + submenu.style.top = rect.top + 'px'; 1504 + submenu.style.left = rect.right + 'px'; 1505 + }; 1506 + 1507 + // Show/hide on hover 1508 + menuItem.addEventListener('mouseenter', () => { 1509 + // Only show when sidebar is not fully open (labels visible) 1510 + if (document.body.classList.contains('sidebar-open')) { 1511 + return; 1512 + } 1513 + positionSubmenu(); 1514 + submenu.classList.add('visible'); 1515 + }); 1516 + 1517 + menuItem.addEventListener('mouseleave', (e) => { 1518 + // Check if moving to submenu 1519 + const related = e.relatedTarget; 1520 + if (related && submenu.contains(related)) { 1521 + return; 1522 + } 1523 + submenu.classList.remove('visible'); 1524 + }); 1525 + 1526 + submenu.addEventListener('mouseleave', (e) => { 1527 + // Check if moving back to menu item 1528 + const related = e.relatedTarget; 1529 + if (related && menuItem.contains(related)) { 1530 + return; 1531 + } 1532 + submenu.classList.remove('visible'); 1533 + }); 1534 + 1535 + // Keep submenu visible while hovering it 1536 + submenu.addEventListener('mouseenter', () => { 1537 + submenu.classList.add('visible'); 1538 + }); 1539 + } 1352 1540 } 1353 1541 };