personal memory agent
0
fork

Configure Feed

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

refactor: extract app.html components and add WebSocket support

Break down monolithic 716-line app.html template into modular components
for better maintainability and performance. Add comprehensive WebSocket
infrastructure for real-time Callosum event streaming.

**New Files:**
- convey/static/websocket.js: Callosum WebSocket bridge with auto-reconnect,
connection metrics (uptime, last message), and window.appEvents API
- convey/static/app.css: All app system styles (368 lines)
- convey/static/app.js: Facet/menu interaction logic (292 lines)
- convey/templates/menu_bar.html: Left sidebar navigation component
- convey/templates/status_pane.html: Status dropdown with live WebSocket metrics

**Modified:**
- convey/templates/app.html: Reduced from 716 to 67 lines (91% reduction)
by extracting CSS, JS, and components into separate cacheable files

**Benefits:**
- Browser-cacheable CSS/JS assets
- Real-time WebSocket status (🟢 connected / 🔴 disconnected)
- Status pane shows connection uptime and last message time
- Self-contained reusable components
- Cleaner separation of concerns
- Apps can now use window.appEvents.listen() for real-time updates

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

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

+950 -687
+368
convey/static/app.css
··· 1 + /** 2 + * App System Styles 3 + * Styles for the app.html template system (facet bar, menu bar, app bar, etc.) 4 + */ 5 + 6 + body { 7 + margin: 0; 8 + padding: 0; 9 + overflow-x: hidden; 10 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 11 + } 12 + 13 + .container { 14 + padding: 0 1em; 15 + margin-top: 60px; 16 + margin-bottom: 1em; 17 + margin-left: 1em; 18 + } 19 + 20 + /* Add bottom margin when app-bar is present */ 21 + body.has-app-bar .container { 22 + margin-bottom: 70px; 23 + } 24 + 25 + /* Facet Bar (top) */ 26 + .facet-bar { 27 + position: fixed; 28 + top: 0; 29 + left: 0; 30 + right: 0; 31 + background: white; 32 + border-bottom: 1px solid var(--facet-border, #e0e0e0); 33 + z-index: 1000; 34 + height: 60px; 35 + padding: 12px 16px; 36 + transition: border-bottom-color 0.3s ease; 37 + overflow: visible; 38 + white-space: nowrap; 39 + display: flex; 40 + align-items: center; 41 + gap: 12px; 42 + } 43 + 44 + .facet-bar::before { 45 + content: ''; 46 + position: absolute; 47 + top: 0; 48 + left: 0; 49 + right: 0; 50 + bottom: 0; 51 + background: var(--facet-bg, transparent); 52 + transition: background-color 0.3s ease; 53 + pointer-events: none; 54 + z-index: -1; 55 + } 56 + 57 + .facet-bar #hamburger { 58 + font-size: 24px; 59 + cursor: pointer; 60 + padding: 8px; 61 + border-radius: 4px; 62 + transition: background 0.2s; 63 + user-select: none; 64 + flex-shrink: 0; 65 + } 66 + 67 + .facet-bar #hamburger:hover { 68 + background: rgba(0,0,0,0.05); 69 + } 70 + 71 + .facet-bar .app-icon { 72 + font-size: 28px; 73 + padding: 4px; 74 + flex-shrink: 0; 75 + cursor: pointer; 76 + border-radius: 4px; 77 + transition: background 0.2s; 78 + text-decoration: none; 79 + } 80 + 81 + .facet-bar .app-icon:hover { 82 + background: rgba(0,0,0,0.05); 83 + } 84 + 85 + .facet-bar .status-icon { 86 + font-size: 20px; 87 + padding: 4px; 88 + flex-shrink: 0; 89 + cursor: pointer; 90 + border-radius: 4px; 91 + transition: background 0.2s; 92 + margin-left: auto; 93 + position: relative; 94 + } 95 + 96 + .facet-bar .status-icon:hover { 97 + background: rgba(0,0,0,0.05); 98 + } 99 + 100 + /* Status Pane */ 101 + .status-pane { 102 + position: fixed; 103 + top: calc(60px + 4px); 104 + right: 16px; 105 + background: white; 106 + border: 1px solid #e0e0e0; 107 + border-radius: 8px; 108 + box-shadow: 0 4px 12px rgba(0,0,0,0.15); 109 + min-width: 280px; 110 + max-width: 400px; 111 + display: none; 112 + z-index: 10000; 113 + } 114 + 115 + .status-pane.visible { 116 + display: block; 117 + } 118 + 119 + .status-pane-content { 120 + padding: 16px; 121 + } 122 + 123 + .status-pane-content h3 { 124 + margin: 0 0 12px 0; 125 + font-size: 16px; 126 + font-weight: 600; 127 + color: #333; 128 + } 129 + 130 + .status-pane-content p { 131 + margin: 0; 132 + font-size: 14px; 133 + color: #666; 134 + } 135 + 136 + .facet-bar .facet-pills-container { 137 + flex: 1; 138 + display: flex; 139 + align-items: center; 140 + justify-content: center; 141 + overflow-x: auto; 142 + overflow-y: visible; 143 + white-space: nowrap; 144 + scrollbar-width: thin; 145 + padding-top: 4px; 146 + padding-bottom: 4px; 147 + } 148 + 149 + .facet-bar .facet-pills-container::-webkit-scrollbar { 150 + height: 6px; 151 + } 152 + 153 + .facet-bar .facet-pills-container::-webkit-scrollbar-thumb { 154 + background: #ccc; 155 + border-radius: 3px; 156 + } 157 + 158 + /* Menu Bar (left sidebar) */ 159 + .menu-bar { 160 + position: fixed; 161 + top: 60px; 162 + left: -240px; 163 + width: 240px; 164 + bottom: 60px; 165 + background: white; 166 + border-right: 1px solid var(--facet-border, #e0e0e0); 167 + z-index: 999; 168 + transition: left 0.3s ease, border-right-color 0.3s ease; 169 + overflow-y: auto; 170 + } 171 + 172 + .menu-bar::before { 173 + content: ''; 174 + position: absolute; 175 + top: 0; 176 + left: 0; 177 + right: 0; 178 + bottom: 0; 179 + background: var(--facet-bg, transparent); 180 + transition: background-color 0.3s ease; 181 + pointer-events: none; 182 + z-index: -1; 183 + } 184 + 185 + body.sidebar-open .menu-bar { 186 + left: 0; 187 + } 188 + 189 + .menu-bar .menu-item { 190 + padding: 8px 16px; 191 + display: flex; 192 + align-items: center; 193 + gap: 10px; 194 + cursor: pointer; 195 + transition: background 0.2s; 196 + border-bottom: none; 197 + text-decoration: none; 198 + color: inherit; 199 + } 200 + 201 + .menu-bar .menu-item:hover { 202 + background: rgba(102, 126, 234, 0.1); 203 + } 204 + 205 + .menu-bar .menu-item.current { 206 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 207 + color: white; 208 + font-weight: 500; 209 + } 210 + 211 + .menu-bar .menu-item .icon { 212 + font-size: 20px; 213 + width: 24px; 214 + text-align: center; 215 + } 216 + 217 + .menu-bar .menu-item .label { 218 + font-size: 14px; 219 + } 220 + 221 + .menu-bar .submenu { 222 + display: flex; 223 + flex-direction: column; 224 + background: rgba(0,0,0,0.03); 225 + border-left: 2px solid rgba(102, 126, 234, 0.3); 226 + } 227 + 228 + .menu-bar .submenu-item { 229 + display: flex; 230 + align-items: center; 231 + justify-content: space-between; 232 + padding: 6px 16px 6px 44px; 233 + font-size: 13px; 234 + color: inherit; 235 + text-decoration: none; 236 + transition: background 0.2s; 237 + border-bottom: none; 238 + } 239 + 240 + .menu-bar .submenu-item:hover { 241 + background: rgba(102, 126, 234, 0.1); 242 + } 243 + 244 + .menu-bar .submenu-badge { 245 + display: inline-block; 246 + background: #667eea; 247 + color: white; 248 + font-size: 10px; 249 + font-weight: 600; 250 + padding: 2px 5px; 251 + border-radius: 8px; 252 + min-width: 18px; 253 + text-align: center; 254 + line-height: 1.2; 255 + margin-left: auto; 256 + } 257 + 258 + .facet-pill { 259 + display: inline-flex; 260 + align-items: center; 261 + padding: 8px 16px; 262 + margin-right: 8px; 263 + border-radius: 20px; 264 + background: #f5f5f5; 265 + border: 1px solid #ddd; 266 + cursor: pointer; 267 + font-size: 14px; 268 + transition: all 0.2s ease; 269 + user-select: none; 270 + position: relative; 271 + } 272 + 273 + .facet-pill.icon-only { 274 + padding: 8px; 275 + } 276 + 277 + .facet-pill.icon-only > span:not(.emoji-container) { 278 + display: none; 279 + } 280 + 281 + .facet-pill.icon-only .emoji-container { 282 + margin-right: 0; 283 + } 284 + 285 + .facet-pill:hover { 286 + border-color: #999; 287 + transform: translateY(-1px); 288 + box-shadow: 0 2px 4px rgba(0,0,0,0.1); 289 + } 290 + 291 + .facet-pill.selected { 292 + border-color: #007bff; 293 + font-weight: 500; 294 + box-shadow: 0 2px 6px rgba(0,123,255,0.2); 295 + } 296 + 297 + .facet-pill .emoji-container { 298 + position: relative; 299 + font-size: 24px; 300 + line-height: 1; 301 + margin-right: 8px; 302 + display: flex; 303 + align-items: center; 304 + } 305 + 306 + .facet-pill .emoji { 307 + display: block; 308 + } 309 + 310 + .facet-pill .facet-badge { 311 + position: absolute; 312 + bottom: 0; 313 + right: -2px; 314 + background: #667eea; 315 + color: white; 316 + font-size: 9px; 317 + font-weight: 600; 318 + padding: 1px 4px; 319 + border-radius: 6px; 320 + min-width: 14px; 321 + text-align: center; 322 + line-height: 1.3; 323 + box-shadow: 0 1px 2px rgba(0,0,0,0.2); 324 + } 325 + 326 + .facet-pill.selected .facet-badge { 327 + background: #007bff; 328 + } 329 + 330 + /* App Bar (bottom) */ 331 + .app-bar { 332 + position: fixed; 333 + bottom: 0; 334 + left: 0; 335 + right: 0; 336 + background: white; 337 + border-top: 1px solid var(--facet-border, #e0e0e0); 338 + z-index: 1000; 339 + height: 60px; 340 + display: flex; 341 + align-items: center; 342 + padding: 0 16px; 343 + gap: 12px; 344 + transition: border-top-color 0.3s ease; 345 + } 346 + 347 + .app-bar::before { 348 + content: ''; 349 + position: absolute; 350 + top: 0; 351 + left: 0; 352 + right: 0; 353 + bottom: 0; 354 + background: var(--facet-bg, transparent); 355 + transition: background-color 0.3s ease; 356 + pointer-events: none; 357 + z-index: -1; 358 + } 359 + 360 + /* Workspace content area */ 361 + .workspace { 362 + flex: 1; 363 + display: flex; 364 + align-items: center; 365 + justify-content: center; 366 + gap: 12px; 367 + overflow: hidden; 368 + }
+292
convey/static/app.js
··· 1 + /** 2 + * App System JavaScript 3 + * Handles facet selection, menu interactions, and responsive UI for app.html 4 + * 5 + * Requires: 6 + * - window.facetsData - Array of facet objects from server 7 + * - window.selectedFacetFromServer - Currently selected facet name or null 8 + * - window.appFacetCounts - Object mapping facet names to counts (injected per-app) 9 + */ 10 + 11 + (function(){ 12 + // Facet filtering state 13 + let activeFacets = []; 14 + let selectedFacet = null; // null means "All" 15 + 16 + // Save facet selection to cookie (server-driven) 17 + function saveSelectedFacetToCookie(facet) { 18 + if (facet) { 19 + const expires = new Date(); 20 + expires.setFullYear(expires.getFullYear() + 1); 21 + document.cookie = `selectedFacet=${facet}; expires=${expires.toUTCString()}; path=/; SameSite=Lax`; 22 + } else { 23 + document.cookie = 'selectedFacet=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Lax'; 24 + } 25 + } 26 + 27 + // Convert hex color to rgba with opacity 28 + function hexToRgba(hex, alpha) { 29 + if (!hex || hex.length < 6) return `rgba(128,128,128,${alpha})`; 30 + const r = parseInt(hex.substring(1,3), 16); 31 + const g = parseInt(hex.substring(3,5), 16); 32 + const b = parseInt(hex.substring(5,7), 16); 33 + return `rgba(${r},${g},${b},${alpha})`; 34 + } 35 + 36 + // Load facets from embedded data 37 + function loadFacetChooser() { 38 + activeFacets = window.facetsData || []; 39 + 40 + // Enrich facets with app-specific counts (injected by app.html) 41 + const appCounts = window.appFacetCounts || {}; 42 + activeFacets.forEach(facet => { 43 + facet.count = appCounts[facet.name] || 0; 44 + }); 45 + 46 + renderFacetChooser(); 47 + } 48 + 49 + // Render facet pills in top bar 50 + function renderFacetChooser() { 51 + const facetPillsContainer = document.querySelector('.facet-pills-container'); 52 + if (!facetPillsContainer) return; 53 + 54 + facetPillsContainer.innerHTML = ''; 55 + 56 + // Find selected facet data 57 + const selectedFacetData = selectedFacet ? activeFacets.find(f => f.name === selectedFacet) : null; 58 + 59 + // Apply theme by updating CSS variables 60 + if (selectedFacetData && selectedFacetData.color) { 61 + const color = selectedFacetData.color; 62 + const bgColor = color + '1a'; // 10% opacity 63 + 64 + document.documentElement.style.setProperty('--facet-color', color); 65 + document.documentElement.style.setProperty('--facet-bg', bgColor); 66 + document.documentElement.style.setProperty('--facet-border', color); 67 + } else { 68 + // Clear facet variables to use defaults 69 + document.documentElement.style.removeProperty('--facet-color'); 70 + document.documentElement.style.removeProperty('--facet-bg'); 71 + document.documentElement.style.removeProperty('--facet-border'); 72 + } 73 + 74 + // Facet pills 75 + activeFacets.forEach(facet => { 76 + const pill = document.createElement('div'); 77 + pill.className = 'facet-pill' + (selectedFacet === facet.name ? ' selected' : ''); 78 + 79 + if (facet.emoji) { 80 + const emojiContainer = document.createElement('div'); 81 + emojiContainer.className = 'emoji-container'; 82 + 83 + const emoji = document.createElement('span'); 84 + emoji.className = 'emoji'; 85 + emoji.textContent = facet.emoji; 86 + emojiContainer.appendChild(emoji); 87 + 88 + // Add badge if count > 0 89 + const count = facet.count || 0; 90 + if (count > 0) { 91 + const badge = document.createElement('span'); 92 + badge.className = 'facet-badge'; 93 + badge.textContent = count; 94 + emojiContainer.appendChild(badge); 95 + } 96 + 97 + pill.appendChild(emojiContainer); 98 + } 99 + 100 + const title = document.createElement('span'); 101 + title.textContent = facet.title; 102 + pill.appendChild(title); 103 + 104 + // Apply color with opacity if facet is selected and has a color 105 + if (selectedFacet === facet.name && facet.color) { 106 + pill.style.background = hexToRgba(facet.color, 0.2); 107 + pill.style.borderColor = facet.color; 108 + } 109 + 110 + pill.onclick = () => selectFacet(facet.name); 111 + facetPillsContainer.appendChild(pill); 112 + }); 113 + } 114 + 115 + // Update selection styles without re-rendering 116 + function updateFacetSelection() { 117 + const container = document.querySelector('.facet-pills-container'); 118 + if (!container) return; 119 + 120 + const pills = container.querySelectorAll('.facet-pill'); 121 + 122 + // Find selected facet data 123 + const selectedFacetData = selectedFacet ? activeFacets.find(f => f.name === selectedFacet) : null; 124 + 125 + // Apply theme by updating CSS variables 126 + if (selectedFacetData && selectedFacetData.color) { 127 + const color = selectedFacetData.color; 128 + const bgColor = color + '1a'; // 10% opacity 129 + 130 + document.documentElement.style.setProperty('--facet-color', color); 131 + document.documentElement.style.setProperty('--facet-bg', bgColor); 132 + document.documentElement.style.setProperty('--facet-border', color); 133 + } else { 134 + // Clear facet variables to use defaults 135 + document.documentElement.style.removeProperty('--facet-color'); 136 + document.documentElement.style.removeProperty('--facet-bg'); 137 + document.documentElement.style.removeProperty('--facet-border'); 138 + } 139 + 140 + // Update pill selection states 141 + pills.forEach((pill, index) => { 142 + const facetName = activeFacets[index]?.name; 143 + 144 + // Update selected class 145 + if (selectedFacet === facetName) { 146 + pill.classList.add('selected'); 147 + 148 + // Apply color styling if selected and has color 149 + if (selectedFacetData && selectedFacetData.color) { 150 + pill.style.background = hexToRgba(selectedFacetData.color, 0.2); 151 + pill.style.borderColor = selectedFacetData.color; 152 + } else { 153 + pill.style.background = ''; 154 + pill.style.borderColor = ''; 155 + } 156 + } else { 157 + pill.classList.remove('selected'); 158 + pill.style.background = ''; 159 + pill.style.borderColor = ''; 160 + } 161 + }); 162 + } 163 + 164 + // Handle facet selection 165 + function selectFacet(facet) { 166 + selectedFacet = facet; 167 + saveSelectedFacetToCookie(facet); 168 + updateFacetSelection(); 169 + } 170 + 171 + // Toggle sidebar 172 + function toggleSidebar() { 173 + document.body.classList.toggle('sidebar-open'); 174 + } 175 + 176 + // Collapse facet pills when container is too narrow 177 + function collapseFacetPills() { 178 + const container = document.querySelector('.facet-pills-container'); 179 + if (!container) return; 180 + 181 + const pills = Array.from(container.querySelectorAll('.facet-pill')); 182 + if (pills.length === 0) return; 183 + 184 + // Reset all pills to full display 185 + pills.forEach(pill => pill.classList.remove('icon-only')); 186 + 187 + // Force a reflow to get accurate measurements 188 + container.offsetWidth; 189 + 190 + // Check if we're overflowing 191 + const containerWidth = container.clientWidth; 192 + let totalWidth = 0; 193 + 194 + pills.forEach(pill => { 195 + totalWidth += pill.offsetWidth + 8; // Include margin 196 + }); 197 + 198 + // If overflowing, collapse pills from right to left 199 + if (totalWidth > containerWidth) { 200 + // Start from the end (right side) and collapse until we fit 201 + for (let i = pills.length - 1; i >= 0; i--) { 202 + const pill = pills[i]; 203 + 204 + pill.classList.add('icon-only'); 205 + 206 + // Force reflow and recalculate 207 + container.offsetWidth; 208 + 209 + totalWidth = 0; 210 + pills.forEach(p => { 211 + totalWidth += p.offsetWidth + 8; 212 + }); 213 + 214 + // If we fit now, stop collapsing 215 + if (totalWidth <= containerWidth) break; 216 + } 217 + } 218 + } 219 + 220 + // Initialize 221 + function init() { 222 + // Initialize facet selection from server 223 + selectedFacet = window.selectedFacetFromServer; 224 + 225 + // Load facet chooser 226 + loadFacetChooser(); 227 + 228 + // Set up ResizeObserver to collapse pills when container width changes 229 + const facetPillsContainer = document.querySelector('.facet-pills-container'); 230 + if (facetPillsContainer) { 231 + const resizeObserver = new ResizeObserver(() => { 232 + collapseFacetPills(); 233 + }); 234 + resizeObserver.observe(facetPillsContainer); 235 + } 236 + 237 + // Initial collapse check after DOM settles 238 + setTimeout(collapseFacetPills, 0); 239 + 240 + // Hamburger menu interactions 241 + const hamburger = document.getElementById('hamburger'); 242 + if (hamburger) { 243 + hamburger.addEventListener('click', (e) => { 244 + e.stopPropagation(); 245 + toggleSidebar(); 246 + }); 247 + } 248 + 249 + // App icon click - clear facet selection 250 + const appIcon = document.querySelector('.facet-bar .app-icon'); 251 + if (appIcon) { 252 + appIcon.addEventListener('click', (e) => { 253 + selectedFacet = null; 254 + saveSelectedFacetToCookie(null); 255 + updateFacetSelection(); 256 + }); 257 + } 258 + 259 + // Handle submenu items with data-facet attribute 260 + document.querySelectorAll('.submenu-item[data-facet]').forEach(item => { 261 + item.addEventListener('click', (e) => { 262 + e.preventDefault(); 263 + const facetName = item.getAttribute('data-facet'); 264 + const targetPath = item.getAttribute('href'); 265 + 266 + // Select the facet (sets cookie and updates UI) 267 + selectFacet(facetName); 268 + 269 + // Navigate to the path 270 + window.location.href = targetPath; 271 + }); 272 + }); 273 + 274 + // Close sidebar when clicking outside 275 + document.addEventListener('click', (e) => { 276 + if (document.body.classList.contains('sidebar-open')) { 277 + const menuBar = document.querySelector('.menu-bar'); 278 + const facetBar = document.querySelector('.facet-bar'); 279 + if (menuBar && facetBar && !menuBar.contains(e.target) && !facetBar.contains(e.target)) { 280 + document.body.classList.remove('sidebar-open'); 281 + } 282 + } 283 + }); 284 + } 285 + 286 + // Run initialization when DOM is ready 287 + if (document.readyState === 'loading') { 288 + document.addEventListener('DOMContentLoaded', init); 289 + } else { 290 + init(); 291 + } 292 + })();
+161
convey/static/websocket.js
··· 1 + /** 2 + * Callosum WebSocket Bridge 3 + * 4 + * Connects to /ws/events and broadcasts Callosum events to registered listeners. 5 + * Provides window.appEvents API for subscribing to events by tract. 6 + */ 7 + (function(){ 8 + const listeners = {}; // Keyed by tract: 'cortex', 'task', 'indexer', etc. 9 + let ws; 10 + let retry = 1000; 11 + let statusIcon = null; 12 + 13 + // Connection metrics 14 + let connectedAt = null; 15 + let lastMessageAt = null; 16 + let isConnected = false; 17 + 18 + // Update status icon (if present) 19 + function updateStatusIcon(connected) { 20 + if (!statusIcon) { 21 + statusIcon = document.querySelector('.facet-bar .status-icon'); 22 + } 23 + 24 + if (statusIcon) { 25 + statusIcon.textContent = connected ? '🟢' : '🔴'; 26 + statusIcon.setAttribute('title', connected ? 'Connected' : 'Disconnected'); 27 + } 28 + 29 + isConnected = connected; 30 + } 31 + 32 + // Connect to WebSocket 33 + function connect(){ 34 + ws = new WebSocket(`ws://${location.host}/ws/events`); 35 + 36 + ws.onopen = () => { 37 + connectedAt = Date.now(); 38 + lastMessageAt = null; 39 + updateStatusIcon(true); 40 + retry = 1000; 41 + console.debug('[WebSocket] Connected to /ws/events'); 42 + }; 43 + 44 + ws.onclose = () => { 45 + connectedAt = null; 46 + lastMessageAt = null; 47 + updateStatusIcon(false); 48 + retry = Math.min(retry * 1.5, 15000); 49 + console.debug(`[WebSocket] Disconnected, reconnecting in ${retry}ms`); 50 + setTimeout(connect, retry); 51 + }; 52 + 53 + ws.onmessage = e => { 54 + lastMessageAt = Date.now(); 55 + 56 + let msg; 57 + try { 58 + msg = JSON.parse(e.data); 59 + } catch(err) { 60 + console.warn('[WebSocket] Failed to parse message:', err); 61 + return; 62 + } 63 + 64 + const tract = msg.tract; 65 + 66 + // Call tract-specific listeners 67 + if(tract && listeners[tract]){ 68 + listeners[tract].forEach(fn => { 69 + try { 70 + fn(msg); 71 + } catch(err) { 72 + console.error(`[WebSocket] Error in ${tract} listener:`, err); 73 + } 74 + }); 75 + } 76 + 77 + // Call wildcard listeners 78 + if(listeners['*']){ 79 + listeners['*'].forEach(fn => { 80 + try { 81 + fn(msg); 82 + } catch(err) { 83 + console.error('[WebSocket] Error in wildcard listener:', err); 84 + } 85 + }); 86 + } 87 + }; 88 + 89 + ws.onerror = (err) => { 90 + console.error('[WebSocket] Error:', err); 91 + }; 92 + } 93 + 94 + // Expose global API 95 + window.appEvents = { 96 + /** 97 + * Listen for events from a specific tract or all events. 98 + * 99 + * @param {string} tract - Tract name ('cortex', 'observe', 'indexer', etc.) or '*' for all 100 + * @param {function} fn - Callback function that receives the event object 101 + * @returns {function} Cleanup function to remove the listener 102 + * 103 + * @example 104 + * // Listen to cortex events 105 + * const cleanup = window.appEvents.listen('cortex', (msg) => { 106 + * console.log('Cortex event:', msg); 107 + * }); 108 + * 109 + * // Later, remove listener 110 + * cleanup(); 111 + * 112 + * @example 113 + * // Listen to all events 114 + * window.appEvents.listen('*', (msg) => { 115 + * console.log('Event:', msg.tract, msg.event); 116 + * }); 117 + */ 118 + listen(tract, fn){ 119 + if(!listeners[tract]) listeners[tract] = []; 120 + listeners[tract].push(fn); 121 + // Return cleanup function 122 + return () => this.unlisten(tract, fn); 123 + }, 124 + 125 + /** 126 + * Remove a specific listener for a tract. 127 + * 128 + * @param {string} tract - Tract name or '*' 129 + * @param {function} fn - The listener function to remove 130 + */ 131 + unlisten(tract, fn){ 132 + if(listeners[tract]){ 133 + listeners[tract] = listeners[tract].filter(f => f !== fn); 134 + } 135 + }, 136 + 137 + /** 138 + * Get connection metrics. 139 + * 140 + * @returns {object} Object with connection status and timing info 141 + */ 142 + getMetrics(){ 143 + const now = Date.now(); 144 + return { 145 + connected: isConnected, 146 + uptimeMs: connectedAt ? now - connectedAt : 0, 147 + lastMessageMs: lastMessageAt ? now - lastMessageAt : null, 148 + lastMessageAt: lastMessageAt, 149 + connectedAt: connectedAt 150 + }; 151 + } 152 + }; 153 + 154 + // Auto-connect when DOM is ready 155 + if (document.readyState === 'loading') { 156 + document.addEventListener('DOMContentLoaded', connect); 157 + } else { 158 + // DOM already loaded, connect immediately 159 + connect(); 160 + } 161 + })();
+10 -687
convey/templates/app.html
··· 5 5 <meta name="viewport" content="width=device-width, initial-scale=1"/> 6 6 <title>{{ app_registry.apps[app].label }} - Sunstone</title> 7 7 <link rel="stylesheet" href="{{ url_for('review.static', filename='review.css') }}"> 8 + <link rel="stylesheet" href="{{ url_for('review.static', filename='app.css') }}"> 8 9 9 10 <!-- Embed facets data for immediate client-side access --> 10 11 <script> 11 12 window.facetsData = {{ facets|tojson|safe }}; 12 13 window.selectedFacetFromServer = {{ selected_facet|tojson|safe }}; 14 + window.appFacetCounts = {{ app_registry.apps[app].get_facet_counts(facets, selected_facet)|tojson|safe }}; 13 15 </script> 16 + 17 + <!-- WebSocket connection for Callosum events --> 18 + <script src="{{ url_for('review.static', filename='websocket.js') }}"></script> 14 19 15 20 <!-- Apply facet theme immediately to prevent flash --> 16 21 {% if selected_facet %} ··· 25 30 </style> 26 31 {% endif %} 27 32 {% endif %} 28 - 29 - <style> 30 - body { 31 - margin: 0; 32 - padding: 0; 33 - overflow-x: hidden; 34 - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 35 - } 36 - 37 - .container { 38 - padding: 0 1em; 39 - margin-top: 60px; 40 - margin-bottom: 1em; 41 - margin-left: 1em; 42 - } 43 - 44 - /* Add bottom margin when app-bar is present */ 45 - body.has-app-bar .container { 46 - margin-bottom: 70px; 47 - } 48 - 49 - /* Facet Bar (top) */ 50 - .facet-bar { 51 - position: fixed; 52 - top: 0; 53 - left: 0; 54 - right: 0; 55 - background: white; 56 - border-bottom: 1px solid var(--facet-border, #e0e0e0); 57 - z-index: 1000; 58 - height: 60px; 59 - padding: 12px 16px; 60 - transition: border-bottom-color 0.3s ease; 61 - overflow: visible; 62 - white-space: nowrap; 63 - display: flex; 64 - align-items: center; 65 - gap: 12px; 66 - } 67 - 68 - .facet-bar::before { 69 - content: ''; 70 - position: absolute; 71 - top: 0; 72 - left: 0; 73 - right: 0; 74 - bottom: 0; 75 - background: var(--facet-bg, transparent); 76 - transition: background-color 0.3s ease; 77 - pointer-events: none; 78 - z-index: -1; 79 - } 80 - 81 - .facet-bar #hamburger { 82 - font-size: 24px; 83 - cursor: pointer; 84 - padding: 8px; 85 - border-radius: 4px; 86 - transition: background 0.2s; 87 - user-select: none; 88 - flex-shrink: 0; 89 - } 90 - 91 - .facet-bar #hamburger:hover { 92 - background: rgba(0,0,0,0.05); 93 - } 94 - 95 - .facet-bar .app-icon { 96 - font-size: 28px; 97 - padding: 4px; 98 - flex-shrink: 0; 99 - cursor: pointer; 100 - border-radius: 4px; 101 - transition: background 0.2s; 102 - text-decoration: none; 103 - } 104 - 105 - .facet-bar .app-icon:hover { 106 - background: rgba(0,0,0,0.05); 107 - } 108 - 109 - .facet-bar .status-icon { 110 - font-size: 20px; 111 - padding: 4px; 112 - flex-shrink: 0; 113 - cursor: pointer; 114 - border-radius: 4px; 115 - transition: background 0.2s; 116 - margin-left: auto; 117 - position: relative; 118 - } 119 - 120 - .facet-bar .status-icon:hover { 121 - background: rgba(0,0,0,0.05); 122 - } 123 - 124 - /* Status Pane */ 125 - .status-pane { 126 - position: fixed; 127 - top: calc(60px + 4px); 128 - right: 16px; 129 - background: white; 130 - border: 1px solid #e0e0e0; 131 - border-radius: 8px; 132 - box-shadow: 0 4px 12px rgba(0,0,0,0.15); 133 - min-width: 280px; 134 - max-width: 400px; 135 - display: none; 136 - z-index: 10000; 137 - } 138 - 139 - .status-pane.visible { 140 - display: block; 141 - } 142 - 143 - .status-pane-content { 144 - padding: 16px; 145 - } 146 - 147 - .status-pane-content h3 { 148 - margin: 0 0 12px 0; 149 - font-size: 16px; 150 - font-weight: 600; 151 - color: #333; 152 - } 153 - 154 - .status-pane-content p { 155 - margin: 0; 156 - font-size: 14px; 157 - color: #666; 158 - } 159 - 160 - .facet-bar .facet-pills-container { 161 - flex: 1; 162 - display: flex; 163 - align-items: center; 164 - justify-content: center; 165 - overflow-x: auto; 166 - overflow-y: visible; 167 - white-space: nowrap; 168 - scrollbar-width: thin; 169 - padding-top: 4px; 170 - padding-bottom: 4px; 171 - } 172 - 173 - .facet-bar .facet-pills-container::-webkit-scrollbar { 174 - height: 6px; 175 - } 176 - 177 - .facet-bar .facet-pills-container::-webkit-scrollbar-thumb { 178 - background: #ccc; 179 - border-radius: 3px; 180 - } 181 - 182 - /* Menu Bar (left sidebar) */ 183 - .menu-bar { 184 - position: fixed; 185 - top: 60px; 186 - left: -240px; 187 - width: 240px; 188 - bottom: 60px; 189 - background: white; 190 - border-right: 1px solid var(--facet-border, #e0e0e0); 191 - z-index: 999; 192 - transition: left 0.3s ease, border-right-color 0.3s ease; 193 - overflow-y: auto; 194 - } 195 - 196 - .menu-bar::before { 197 - content: ''; 198 - position: absolute; 199 - top: 0; 200 - left: 0; 201 - right: 0; 202 - bottom: 0; 203 - background: var(--facet-bg, transparent); 204 - transition: background-color 0.3s ease; 205 - pointer-events: none; 206 - z-index: -1; 207 - } 208 - 209 - body.sidebar-open .menu-bar { 210 - left: 0; 211 - } 212 - 213 - .menu-bar .menu-item { 214 - padding: 8px 16px; 215 - display: flex; 216 - align-items: center; 217 - gap: 10px; 218 - cursor: pointer; 219 - transition: background 0.2s; 220 - border-bottom: none; 221 - text-decoration: none; 222 - color: inherit; 223 - } 224 - 225 - .menu-bar .menu-item:hover { 226 - background: rgba(102, 126, 234, 0.1); 227 - } 228 - 229 - .menu-bar .menu-item.current { 230 - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 231 - color: white; 232 - font-weight: 500; 233 - } 234 - 235 - .menu-bar .menu-item .icon { 236 - font-size: 20px; 237 - width: 24px; 238 - text-align: center; 239 - } 240 - 241 - .menu-bar .menu-item .label { 242 - font-size: 14px; 243 - } 244 - 245 - .menu-bar .submenu { 246 - display: flex; 247 - flex-direction: column; 248 - background: rgba(0,0,0,0.03); 249 - border-left: 2px solid rgba(102, 126, 234, 0.3); 250 - } 251 - 252 - .menu-bar .submenu-item { 253 - display: flex; 254 - align-items: center; 255 - justify-content: space-between; 256 - padding: 6px 16px 6px 44px; 257 - font-size: 13px; 258 - color: inherit; 259 - text-decoration: none; 260 - transition: background 0.2s; 261 - border-bottom: none; 262 - } 263 - 264 - .menu-bar .submenu-item:hover { 265 - background: rgba(102, 126, 234, 0.1); 266 - } 267 - 268 - .menu-bar .submenu-badge { 269 - display: inline-block; 270 - background: #667eea; 271 - color: white; 272 - font-size: 10px; 273 - font-weight: 600; 274 - padding: 2px 5px; 275 - border-radius: 8px; 276 - min-width: 18px; 277 - text-align: center; 278 - line-height: 1.2; 279 - margin-left: auto; 280 - } 281 - 282 - .facet-pill { 283 - display: inline-flex; 284 - align-items: center; 285 - padding: 8px 16px; 286 - margin-right: 8px; 287 - border-radius: 20px; 288 - background: #f5f5f5; 289 - border: 1px solid #ddd; 290 - cursor: pointer; 291 - font-size: 14px; 292 - transition: all 0.2s ease; 293 - user-select: none; 294 - position: relative; 295 - } 296 - 297 - .facet-pill.icon-only { 298 - padding: 8px; 299 - } 300 - 301 - .facet-pill.icon-only > span:not(.emoji-container) { 302 - display: none; 303 - } 304 - 305 - .facet-pill.icon-only .emoji-container { 306 - margin-right: 0; 307 - } 308 - 309 - .facet-pill:hover { 310 - border-color: #999; 311 - transform: translateY(-1px); 312 - box-shadow: 0 2px 4px rgba(0,0,0,0.1); 313 - } 314 - 315 - .facet-pill.selected { 316 - border-color: #007bff; 317 - font-weight: 500; 318 - box-shadow: 0 2px 6px rgba(0,123,255,0.2); 319 - } 320 - 321 - .facet-pill .emoji-container { 322 - position: relative; 323 - font-size: 24px; 324 - line-height: 1; 325 - margin-right: 8px; 326 - display: flex; 327 - align-items: center; 328 - } 329 - 330 - .facet-pill .emoji { 331 - display: block; 332 - } 333 - 334 - .facet-pill .facet-badge { 335 - position: absolute; 336 - bottom: 0; 337 - right: -2px; 338 - background: #667eea; 339 - color: white; 340 - font-size: 9px; 341 - font-weight: 600; 342 - padding: 1px 4px; 343 - border-radius: 6px; 344 - min-width: 14px; 345 - text-align: center; 346 - line-height: 1.3; 347 - box-shadow: 0 1px 2px rgba(0,0,0,0.2); 348 - } 349 - 350 - .facet-pill.selected .facet-badge { 351 - background: #007bff; 352 - } 353 - 354 - /* App Bar (bottom) */ 355 - .app-bar { 356 - position: fixed; 357 - bottom: 0; 358 - left: 0; 359 - right: 0; 360 - background: white; 361 - border-top: 1px solid var(--facet-border, #e0e0e0); 362 - z-index: 1000; 363 - height: 60px; 364 - display: flex; 365 - align-items: center; 366 - padding: 0 16px; 367 - gap: 12px; 368 - transition: border-top-color 0.3s ease; 369 - } 370 - 371 - .app-bar::before { 372 - content: ''; 373 - position: absolute; 374 - top: 0; 375 - left: 0; 376 - right: 0; 377 - bottom: 0; 378 - background: var(--facet-bg, transparent); 379 - transition: background-color 0.3s ease; 380 - pointer-events: none; 381 - z-index: -1; 382 - } 383 - 384 - /* Workspace content area */ 385 - .workspace { 386 - flex: 1; 387 - display: flex; 388 - align-items: center; 389 - justify-content: center; 390 - gap: 12px; 391 - overflow: hidden; 392 - } 393 - </style> 394 33 </head> 395 34 <body{% if app_registry.apps[app].get_app_bar_template() %} class="has-app-bar"{% endif %}> 396 - <!-- Menu Bar (left sidebar) --> 397 - <div class="menu-bar"> 398 - {% for app_name, app_data in apps.items() %} 399 - <a href="/app/{{ app_name }}" class="menu-item{% if app == app_name %} current{% endif %}"> 400 - <span class="icon">{{ app_data['icon'] }}</span> 401 - <span class="label">{{ app_data['label'] }}</span> 402 - </a> 403 - {% if app_data.get('submenu') %} 404 - <div class="submenu"> 405 - {% for item in app_data['submenu'] %} 406 - <a href="{{ item['path'] }}" class="submenu-item" {% if item.get('facet') %}data-facet="{{ item['facet'] }}"{% endif %}> 407 - <span>{{ item['label'] }}</span> 408 - {% if item.get('count') and item['count'] > 0 %} 409 - <span class="submenu-badge">{{ item['count'] }}</span> 410 - {% endif %} 411 - </a> 412 - {% endfor %} 413 - </div> 414 - {% endif %} 415 - {% endfor %} 416 - </div> 35 + <!-- Menu Bar --> 36 + {% include "menu_bar.html" %} 417 37 418 38 <!-- Facet Bar (top) --> 419 39 <div class="facet-bar"> ··· 424 44 </div> 425 45 426 46 <!-- Status Pane --> 427 - <div class="status-pane"> 428 - <div class="status-pane-content"> 429 - <h3>System Status</h3> 430 - <p>All services operational</p> 431 - </div> 432 - </div> 47 + {% include "status_pane.html" %} 433 48 434 49 <!-- App Bar (bottom) - only render if app provides template --> 435 50 {% set app_bar_template = app_registry.apps[app].get_app_bar_template() %} ··· 446 61 {% include app_registry.apps[app].get_workspace_template() %} 447 62 </div> 448 63 449 - <script> 450 - // Facet filtering state 451 - let activeFacets = []; 452 - let selectedFacet = null; // null means "All" 453 - 454 - // Save facet selection to cookie (server-driven) 455 - function saveSelectedFacetToCookie(facet) { 456 - if (facet) { 457 - const expires = new Date(); 458 - expires.setFullYear(expires.getFullYear() + 1); 459 - document.cookie = `selectedFacet=${facet}; expires=${expires.toUTCString()}; path=/; SameSite=Lax`; 460 - } else { 461 - document.cookie = 'selectedFacet=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Lax'; 462 - } 463 - } 464 - 465 - // Convert hex color to rgba with opacity 466 - function hexToRgba(hex, alpha) { 467 - if (!hex || hex.length < 6) return `rgba(128,128,128,${alpha})`; 468 - const r = parseInt(hex.substring(1,3), 16); 469 - const g = parseInt(hex.substring(3,5), 16); 470 - const b = parseInt(hex.substring(5,7), 16); 471 - return `rgba(${r},${g},${b},${alpha})`; 472 - } 473 - 474 - // Load facets from embedded data 475 - function loadFacetChooser() { 476 - activeFacets = window.facetsData || []; 477 - 478 - // Enrich facets with app-specific counts 479 - const appCounts = {{ app_registry.apps[app].get_facet_counts(facets, selected_facet)|tojson|safe }}; 480 - activeFacets.forEach(facet => { 481 - facet.count = appCounts[facet.name] || 0; 482 - }); 483 - 484 - renderFacetChooser(); 485 - } 486 - 487 - // Render facet pills in top bar 488 - function renderFacetChooser() { 489 - const facetPillsContainer = document.querySelector('.facet-pills-container'); 490 - facetPillsContainer.innerHTML = ''; 491 - 492 - // Find selected facet data 493 - const selectedFacetData = selectedFacet ? activeFacets.find(f => f.name === selectedFacet) : null; 494 - 495 - // Apply theme by updating CSS variables 496 - if (selectedFacetData && selectedFacetData.color) { 497 - const color = selectedFacetData.color; 498 - const bgColor = color + '1a'; // 10% opacity 499 - 500 - document.documentElement.style.setProperty('--facet-color', color); 501 - document.documentElement.style.setProperty('--facet-bg', bgColor); 502 - document.documentElement.style.setProperty('--facet-border', color); 503 - } else { 504 - // Clear facet variables to use defaults 505 - document.documentElement.style.removeProperty('--facet-color'); 506 - document.documentElement.style.removeProperty('--facet-bg'); 507 - document.documentElement.style.removeProperty('--facet-border'); 508 - } 509 - 510 - // Facet pills 511 - activeFacets.forEach(facet => { 512 - const pill = document.createElement('div'); 513 - pill.className = 'facet-pill' + (selectedFacet === facet.name ? ' selected' : ''); 514 - 515 - if (facet.emoji) { 516 - const emojiContainer = document.createElement('div'); 517 - emojiContainer.className = 'emoji-container'; 518 - 519 - const emoji = document.createElement('span'); 520 - emoji.className = 'emoji'; 521 - emoji.textContent = facet.emoji; 522 - emojiContainer.appendChild(emoji); 523 - 524 - // Add badge if count > 0 525 - const count = facet.count || 0; 526 - if (count > 0) { 527 - const badge = document.createElement('span'); 528 - badge.className = 'facet-badge'; 529 - badge.textContent = count; 530 - emojiContainer.appendChild(badge); 531 - } 532 - 533 - pill.appendChild(emojiContainer); 534 - } 535 - 536 - const title = document.createElement('span'); 537 - title.textContent = facet.title; 538 - pill.appendChild(title); 539 - 540 - // Apply color with opacity if facet is selected and has a color 541 - if (selectedFacet === facet.name && facet.color) { 542 - pill.style.background = hexToRgba(facet.color, 0.2); 543 - pill.style.borderColor = facet.color; 544 - } 545 - 546 - pill.onclick = () => selectFacet(facet.name); 547 - facetPillsContainer.appendChild(pill); 548 - }); 549 - } 550 - 551 - // Update selection styles without re-rendering 552 - function updateFacetSelection() { 553 - const container = document.querySelector('.facet-pills-container'); 554 - const pills = container.querySelectorAll('.facet-pill'); 555 - 556 - // Find selected facet data 557 - const selectedFacetData = selectedFacet ? activeFacets.find(f => f.name === selectedFacet) : null; 558 - 559 - // Apply theme by updating CSS variables 560 - if (selectedFacetData && selectedFacetData.color) { 561 - const color = selectedFacetData.color; 562 - const bgColor = color + '1a'; // 10% opacity 563 - 564 - document.documentElement.style.setProperty('--facet-color', color); 565 - document.documentElement.style.setProperty('--facet-bg', bgColor); 566 - document.documentElement.style.setProperty('--facet-border', color); 567 - } else { 568 - // Clear facet variables to use defaults 569 - document.documentElement.style.removeProperty('--facet-color'); 570 - document.documentElement.style.removeProperty('--facet-bg'); 571 - document.documentElement.style.removeProperty('--facet-border'); 572 - } 573 - 574 - // Update pill selection states 575 - pills.forEach((pill, index) => { 576 - const facetName = activeFacets[index]?.name; 577 - 578 - // Update selected class 579 - if (selectedFacet === facetName) { 580 - pill.classList.add('selected'); 581 - 582 - // Apply color styling if selected and has color 583 - if (selectedFacetData && selectedFacetData.color) { 584 - pill.style.background = hexToRgba(selectedFacetData.color, 0.2); 585 - pill.style.borderColor = selectedFacetData.color; 586 - } else { 587 - pill.style.background = ''; 588 - pill.style.borderColor = ''; 589 - } 590 - } else { 591 - pill.classList.remove('selected'); 592 - pill.style.background = ''; 593 - pill.style.borderColor = ''; 594 - } 595 - }); 596 - } 597 - 598 - // Handle facet selection 599 - function selectFacet(facet) { 600 - selectedFacet = facet; 601 - saveSelectedFacetToCookie(facet); 602 - updateFacetSelection(); 603 - } 604 - 605 - // Toggle sidebar 606 - function toggleSidebar() { 607 - document.body.classList.toggle('sidebar-open'); 608 - } 609 - 610 - // Initialize facet selection from server 611 - selectedFacet = window.selectedFacetFromServer; 612 - 613 - // Load facet chooser 614 - loadFacetChooser(); 615 - 616 - // Use ResizeObserver to collapse pills when container width changes 617 - const facetPillsContainer = document.querySelector('.facet-pills-container'); 618 - 619 - if (facetPillsContainer) { 620 - const resizeObserver = new ResizeObserver(() => { 621 - collapseFacetPills(); 622 - }); 623 - 624 - resizeObserver.observe(facetPillsContainer); 625 - } 626 - 627 - function collapseFacetPills() { 628 - const container = document.querySelector('.facet-pills-container'); 629 - const pills = Array.from(container.querySelectorAll('.facet-pill')); 630 - 631 - if (!container || pills.length === 0) return; 632 - 633 - // Reset all pills to full display 634 - pills.forEach(pill => pill.classList.remove('icon-only')); 635 - 636 - // Force a reflow to get accurate measurements 637 - container.offsetWidth; 638 - 639 - // Check if we're overflowing 640 - const containerWidth = container.clientWidth; 641 - let totalWidth = 0; 642 - 643 - pills.forEach(pill => { 644 - totalWidth += pill.offsetWidth + 8; // Include margin 645 - }); 646 - 647 - // If overflowing, collapse pills from right to left 648 - if (totalWidth > containerWidth) { 649 - // Start from the end (right side) and collapse until we fit 650 - for (let i = pills.length - 1; i >= 0; i--) { 651 - const pill = pills[i]; 652 - 653 - pill.classList.add('icon-only'); 654 - 655 - // Force reflow and recalculate 656 - container.offsetWidth; 657 - 658 - totalWidth = 0; 659 - pills.forEach(p => { 660 - totalWidth += p.offsetWidth + 8; 661 - }); 662 - 663 - // If we fit now, stop collapsing 664 - if (totalWidth <= containerWidth) break; 665 - } 666 - } 667 - } 668 - 669 - // Initial collapse check after DOM settles 670 - setTimeout(collapseFacetPills, 0); 671 - 672 - // Hamburger menu interactions 673 - const hamburger = document.getElementById('hamburger'); 674 - 675 - hamburger.addEventListener('click', (e) => { 676 - e.stopPropagation(); 677 - toggleSidebar(); 678 - }); 679 - 680 - // App icon click - clear facet selection 681 - const appIcon = document.querySelector('.facet-bar .app-icon'); 682 - 683 - if (appIcon) { 684 - appIcon.addEventListener('click', (e) => { 685 - selectedFacet = null; 686 - saveSelectedFacetToCookie(null); 687 - updateFacetSelection(); 688 - }); 689 - } 690 - 691 - // Handle submenu items with data-facet attribute 692 - document.querySelectorAll('.submenu-item[data-facet]').forEach(item => { 693 - item.addEventListener('click', (e) => { 694 - e.preventDefault(); 695 - const facetName = item.getAttribute('data-facet'); 696 - const targetPath = item.getAttribute('href'); 697 - 698 - // Select the facet (sets cookie and updates UI) 699 - selectFacet(facetName); 700 - 701 - // Navigate to the path 702 - window.location.href = targetPath; 703 - }); 704 - }); 705 - 706 - // Status icon and pane 707 - const statusIcon = document.querySelector('.status-icon'); 708 - const statusPane = document.querySelector('.status-pane'); 709 - let statusPaneOpen = false; 710 - 711 - if (statusIcon && statusPane) { 712 - statusIcon.addEventListener('click', (e) => { 713 - e.stopPropagation(); 714 - statusPaneOpen = !statusPaneOpen; 715 - 716 - if (statusPaneOpen) { 717 - statusPane.classList.add('visible'); 718 - } else { 719 - statusPane.classList.remove('visible'); 720 - } 721 - }); 722 - } 723 - 724 - // Close sidebar and status pane when clicking outside 725 - document.addEventListener('click', (e) => { 726 - // Close sidebar 727 - if (document.body.classList.contains('sidebar-open')) { 728 - const menuBar = document.querySelector('.menu-bar'); 729 - const facetBar = document.querySelector('.facet-bar'); 730 - if (!menuBar.contains(e.target) && !facetBar.contains(e.target)) { 731 - document.body.classList.remove('sidebar-open'); 732 - } 733 - } 734 - 735 - // Close status pane 736 - if (statusPaneOpen && statusPane && statusIcon && 737 - !statusIcon.contains(e.target) && !statusPane.contains(e.target)) { 738 - statusPaneOpen = false; 739 - statusPane.classList.remove('visible'); 740 - } 741 - }); 742 - </script> 64 + <!-- App JavaScript --> 65 + <script src="{{ url_for('review.static', filename='app.js') }}"></script> 743 66 </body> 744 67 </html>
+21
convey/templates/menu_bar.html
··· 1 + <!-- Menu Bar Component (left sidebar) --> 2 + <div class="menu-bar"> 3 + {% for app_name, app_data in apps.items() %} 4 + <a href="/app/{{ app_name }}" class="menu-item{% if app == app_name %} current{% endif %}"> 5 + <span class="icon">{{ app_data['icon'] }}</span> 6 + <span class="label">{{ app_data['label'] }}</span> 7 + </a> 8 + {% if app_data.get('submenu') %} 9 + <div class="submenu"> 10 + {% for item in app_data['submenu'] %} 11 + <a href="{{ item['path'] }}" class="submenu-item" {% if item.get('facet') %}data-facet="{{ item['facet'] }}"{% endif %}> 12 + <span>{{ item['label'] }}</span> 13 + {% if item.get('count') and item['count'] > 0 %} 14 + <span class="submenu-badge">{{ item['count'] }}</span> 15 + {% endif %} 16 + </a> 17 + {% endfor %} 18 + </div> 19 + {% endif %} 20 + {% endfor %} 21 + </div>
+98
convey/templates/status_pane.html
··· 1 + <!-- Status Pane Component --> 2 + <div class="status-pane"> 3 + <div class="status-pane-content"> 4 + <h3>WebSocket Status</h3> 5 + <div style="display: flex; flex-direction: column; gap: 8px; font-size: 14px;"> 6 + <div> 7 + <strong>Status:</strong> <span id="ws-status">Connecting...</span> 8 + </div> 9 + <div> 10 + <strong>Uptime:</strong> <span id="ws-uptime">-</span> 11 + </div> 12 + <div> 13 + <strong>Last message:</strong> <span id="ws-last-message">-</span> 14 + </div> 15 + </div> 16 + </div> 17 + </div> 18 + 19 + <script> 20 + // Status pane toggle logic 21 + (function(){ 22 + const statusIcon = document.querySelector('.facet-bar .status-icon'); 23 + const statusPane = document.querySelector('.status-pane'); 24 + let statusPaneOpen = false; 25 + 26 + if (statusIcon && statusPane) { 27 + statusIcon.addEventListener('click', (e) => { 28 + e.stopPropagation(); 29 + statusPaneOpen = !statusPaneOpen; 30 + 31 + if (statusPaneOpen) { 32 + statusPane.classList.add('visible'); 33 + } else { 34 + statusPane.classList.remove('visible'); 35 + } 36 + }); 37 + 38 + // Close status pane when clicking outside 39 + document.addEventListener('click', (e) => { 40 + if (statusPaneOpen && statusPane && statusIcon && 41 + !statusIcon.contains(e.target) && !statusPane.contains(e.target)) { 42 + statusPaneOpen = false; 43 + statusPane.classList.remove('visible'); 44 + } 45 + }); 46 + } 47 + 48 + // Update status pane metrics 49 + function updateStatusPane() { 50 + if (!window.appEvents) return; 51 + 52 + const metrics = window.appEvents.getMetrics(); 53 + const wsStatus = document.getElementById('ws-status'); 54 + const wsUptime = document.getElementById('ws-uptime'); 55 + const wsLastMessage = document.getElementById('ws-last-message'); 56 + 57 + if (wsStatus) { 58 + wsStatus.textContent = metrics.connected ? '🟢 Connected' : '🔴 Disconnected'; 59 + wsStatus.style.color = metrics.connected ? '#10b981' : '#ef4444'; 60 + } 61 + 62 + if (wsUptime) { 63 + if (metrics.connected) { 64 + const seconds = Math.floor(metrics.uptimeMs / 1000); 65 + wsUptime.textContent = formatDuration(seconds); 66 + } else { 67 + wsUptime.textContent = '-'; 68 + } 69 + } 70 + 71 + if (wsLastMessage) { 72 + if (metrics.lastMessageMs !== null) { 73 + const seconds = Math.floor(metrics.lastMessageMs / 1000); 74 + wsLastMessage.textContent = seconds === 0 ? 'Just now' : `${formatDuration(seconds)} ago`; 75 + } else if (metrics.connected) { 76 + wsLastMessage.textContent = 'No messages yet'; 77 + } else { 78 + wsLastMessage.textContent = '-'; 79 + } 80 + } 81 + } 82 + 83 + function formatDuration(seconds) { 84 + if (seconds < 60) return `${seconds}s`; 85 + const minutes = Math.floor(seconds / 60); 86 + const secs = seconds % 60; 87 + if (minutes < 60) return `${minutes}m ${secs}s`; 88 + const hours = Math.floor(minutes / 60); 89 + const mins = minutes % 60; 90 + return `${hours}h ${mins}m`; 91 + } 92 + 93 + // Update status pane every second 94 + setInterval(updateStatusPane, 1000); 95 + // Initial update 96 + setTimeout(updateStatusPane, 100); 97 + })(); 98 + </script>