personal memory agent
0
fork

Configure Feed

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

Merge branch 'hopper-4rt42cm5-health-progressive-disclosure'

+268 -33
+268 -33
apps/health/workspace.html
··· 27 27 background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); 28 28 } 29 29 30 + .status-summary { 31 + background: #f9fafb; 32 + border-radius: 10px; 33 + padding: 0.8em 1.5em; 34 + font-size: 0.95em; 35 + color: #374151; 36 + } 37 + 30 38 .vitals-header { 31 39 display: flex; 32 40 justify-content: space-between; ··· 186 194 } 187 195 188 196 .observe-content { 189 - display: grid; 190 - grid-template-columns: repeat(6, 1fr); 191 - gap: 1em; 197 + display: flex; 198 + gap: 2em; 192 199 } 193 200 194 201 .observe-content.hidden { 195 202 display: none; 203 + } 204 + 205 + .observe-group { 206 + flex: 1; 207 + } 208 + 209 + .observe-group-label { 210 + font-size: 0.75em; 211 + color: #9ca3af; 212 + text-transform: uppercase; 213 + letter-spacing: 0.5px; 214 + margin-bottom: 0.5em; 215 + font-weight: 500; 216 + } 217 + 218 + .observe-group-channels { 219 + display: grid; 220 + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); 221 + gap: 1em; 196 222 } 197 223 198 224 .observe-empty { ··· 444 470 box-shadow: 0 2px 8px rgba(0,0,0,0.1); 445 471 } 446 472 473 + .logs-card.logs-collapsed .logs-viewport, 474 + .logs-card.logs-collapsed .logs-controls { 475 + display: none; 476 + } 477 + 478 + .logs-card.logs-collapsed .logs-header { 479 + margin-bottom: 0; 480 + } 481 + 447 482 .logs-header { 448 483 display: flex; 449 - justify-content: space-between; 484 + justify-content: flex-start; 450 485 align-items: center; 486 + gap: 0.75em; 451 487 margin-bottom: 1em; 488 + cursor: pointer; 489 + user-select: none; 452 490 } 453 491 454 492 .logs-title { 455 493 font-size: 1.1em; 456 494 font-weight: 600; 495 + } 496 + 497 + .logs-error-badge { 498 + background: #ef4444; 499 + color: white; 500 + padding: 0.15em 0.5em; 501 + border-radius: 10px; 502 + font-size: 0.8em; 503 + margin-left: 0.5em; 504 + } 505 + 506 + .logs-error-badge.hidden { 507 + display: none; 508 + } 509 + 510 + .logs-collapse-indicator { 511 + font-size: 0.8em; 512 + color: #6b7280; 513 + margin-left: auto; 457 514 } 458 515 459 516 .logs-controls { ··· 699 756 </div> 700 757 </div> 701 758 759 + <div class="status-summary"> 760 + <span id="statusSummaryText">Waiting for status...</span> 761 + </div> 762 + 702 763 <!-- Observe Card --> 703 764 <div class="observe-card"> 704 765 <div class="card-header"> ··· 713 774 No observers connected — <a href="/app/remote/">Manage remotes →</a> 714 775 </div> 715 776 <div class="observe-content hidden" id="observeContent"> 716 - <div class="observe-section"> 717 - <div class="observe-section-title">Screencast</div> 718 - <div class="observe-section-value" id="screencastStatus">Waiting...</div> 719 - <div class="observe-section-detail" id="screencastDetail"></div> 777 + <div class="observe-group"> 778 + <div class="observe-group-label">Capture</div> 779 + <div class="observe-group-channels"> 780 + <div class="observe-section"> 781 + <div class="observe-section-title">Screencast</div> 782 + <div class="observe-section-value" id="screencastStatus">Waiting...</div> 783 + <div class="observe-section-detail" id="screencastDetail"></div> 784 + </div> 785 + <div class="observe-section"> 786 + <div class="observe-section-title">Tmux</div> 787 + <div class="observe-section-value" id="tmuxStatus">Waiting...</div> 788 + <div class="observe-section-detail" id="tmuxDetail"></div> 789 + </div> 790 + <div class="observe-section"> 791 + <div class="observe-section-title">Audio</div> 792 + <div class="observe-section-value" id="audioStatus">Waiting...</div> 793 + <div class="observe-section-detail" id="audioDetail"></div> 794 + </div> 795 + <div class="observe-section"> 796 + <div class="observe-section-title">Activity</div> 797 + <div class="observe-section-value" id="activityStatus">Waiting...</div> 798 + <div class="observe-section-detail" id="activityDetail"></div> 799 + </div> 800 + </div> 720 801 </div> 721 - <div class="observe-section"> 722 - <div class="observe-section-title">Tmux</div> 723 - <div class="observe-section-value" id="tmuxStatus">Waiting...</div> 724 - <div class="observe-section-detail" id="tmuxDetail"></div> 725 - </div> 726 - <div class="observe-section"> 727 - <div class="observe-section-title">Audio</div> 728 - <div class="observe-section-value" id="audioStatus">Waiting...</div> 729 - <div class="observe-section-detail" id="audioDetail"></div> 730 - </div> 731 - <div class="observe-section"> 732 - <div class="observe-section-title">Activity</div> 733 - <div class="observe-section-value" id="activityStatus">Waiting...</div> 734 - <div class="observe-section-detail" id="activityDetail"></div> 735 - </div> 736 - <div class="observe-section"> 737 - <div class="observe-section-title">Describe</div> 738 - <div class="observe-section-value" id="describeStatus">Idle</div> 739 - <div class="observe-section-detail" id="describeDetail"></div> 740 - </div> 741 - <div class="observe-section"> 742 - <div class="observe-section-title">Transcribe</div> 743 - <div class="observe-section-value" id="transcribeStatus">Idle</div> 744 - <div class="observe-section-detail" id="transcribeDetail"></div> 802 + <div class="observe-group"> 803 + <div class="observe-group-label">Processing</div> 804 + <div class="observe-group-channels"> 805 + <div class="observe-section"> 806 + <div class="observe-section-title">Describe</div> 807 + <div class="observe-section-value" id="describeStatus">Idle</div> 808 + <div class="observe-section-detail" id="describeDetail"></div> 809 + </div> 810 + <div class="observe-section"> 811 + <div class="observe-section-title">Transcribe</div> 812 + <div class="observe-section-value" id="transcribeStatus">Idle</div> 813 + <div class="observe-section-detail" id="transcribeDetail"></div> 814 + </div> 815 + </div> 745 816 </div> 746 817 </div> 747 818 </div> ··· 775 846 </div> 776 847 </div> 777 848 849 + <div class="activity-section hidden" id="errorSummary"> 850 + <div class="activity-section-header" style="color: #dc2626;"> 851 + RECENT ERRORS 852 + </div> 853 + <div id="errorSummaryContent"></div> 854 + </div> 855 + 856 + <div class="activity-section hidden" id="allQuietCard" style="text-align: center; color: #6b7280; padding: 2em;"> 857 + All quiet — no active agents, imports, or processing 858 + </div> 859 + 778 860 <!-- Dream Card (hidden when idle) --> 779 861 <div class="dream-card hidden" id="dreamCard"> 780 862 <div class="card-header"> ··· 794 876 </div> 795 877 796 878 <!-- Service Logs --> 797 - <div class="logs-card"> 879 + <div class="logs-card logs-collapsed"> 798 880 <div class="logs-header"> 799 881 <div class="logs-title">SERVICE LOGS</div> 882 + <span class="logs-error-badge hidden" id="logErrorBadge"></span> 883 + <span class="logs-collapse-indicator" id="logsCollapseIndicator">▶ Show</span> 800 884 <div class="logs-controls"> 801 885 <select id="logServiceFilter"> 802 886 <option value="all">All Services</option> ··· 838 922 sync: null, // Sync status snapshot (null when idle) 839 923 serviceLogs: new Map(), // service name -> array of {ts, stream, line} 840 924 logFollow: true, // Auto-scroll log viewport 925 + logsCollapsed: true, 926 + logErrorCount: 0, 841 927 observers: new Map(), // keyed by stream name 928 + recentErrors: [], 842 929 localHost: null, 843 930 deepLinkMode: false, 844 931 lastLogFilter: null, // Last rendered filter state for incremental append ··· 852 939 tasksValue: document.getElementById('tasksValue'), 853 940 healthValue: document.getElementById('healthValue'), 854 941 vitalsStatus: document.getElementById('vitalsStatus'), 942 + statusSummaryText: document.getElementById('statusSummaryText'), 855 943 observeModeBadge: document.getElementById('observeModeBadge'), 856 944 observeModeLabel: document.getElementById('observeModeLabel'), 857 945 screencastStatus: document.getElementById('screencastStatus'), ··· 874 962 cortexGrid: document.getElementById('cortexGrid'), 875 963 importerSection: document.getElementById('importerSection'), 876 964 importerGrid: document.getElementById('importerGrid'), 965 + errorSummary: document.getElementById('errorSummary'), 966 + errorSummaryContent: document.getElementById('errorSummaryContent'), 967 + allQuietCard: document.getElementById('allQuietCard'), 877 968 dreamCard: document.getElementById('dreamCard'), 878 969 dreamInfo: document.getElementById('dreamInfo'), 879 970 dreamProgress: document.getElementById('dreamProgress'), ··· 889 980 logStreamFilter: document.getElementById('logStreamFilter'), 890 981 logFollowBtn: document.getElementById('logFollowBtn'), 891 982 logClearBtn: document.getElementById('logClearBtn'), 983 + logErrorBadge: document.getElementById('logErrorBadge'), 984 + logsCollapseIndicator: document.getElementById('logsCollapseIndicator'), 892 985 connectionIndicator: document.getElementById('connectionIndicator'), 893 986 vitalsBar: document.querySelector('.vitals-bar'), 894 987 }; ··· 932 1025 return `in ${days}d`; 933 1026 } 934 1027 1028 + function updateStatusSummary() { 1029 + const parts = []; 1030 + const observers = Array.from(state.observers.values()); 1031 + const activeAgents = Array.from(state.agents.values()).filter(agent => agent.event !== 'finish' && agent.event !== 'error').length; 1032 + const activeImports = Array.from(state.imports.values()).filter(imp => imp.event !== 'completed' && imp.event !== 'error').length; 1033 + const staleCount = state.health?.stale_heartbeats?.length || 0; 1034 + const servicesKnown = state.services.size > 0 || state.crashed.size > 0; 1035 + 1036 + if (state.observers.size > 0) { 1037 + const captureDetails = []; 1038 + if (observers.some(observer => observer.screencast?.recording)) captureDetails.push('screen'); 1039 + if (observers.some(observer => observer.audio && ((observer.audio.threshold_hits || 0) > 0 || observer.audio.will_save))) captureDetails.push('audio'); 1040 + if (observers.some(observer => observer.tmux?.capturing)) captureDetails.push('tmux'); 1041 + parts.push(captureDetails.length > 0 ? `Capturing (${captureDetails.join(', ')})` : 'Capturing'); 1042 + } 1043 + 1044 + const processingDetails = []; 1045 + if (observers.some(observer => observer.describe?.running)) processingDetails.push('describe'); 1046 + if (observers.some(observer => observer.transcribe?.running)) processingDetails.push('transcribe'); 1047 + if (processingDetails.length > 0) { 1048 + parts.push(`Processing (${processingDetails.join(', ')})`); 1049 + } 1050 + 1051 + if (activeAgents > 0) { 1052 + parts.push(`${activeAgents} agent${activeAgents === 1 ? '' : 's'} running`); 1053 + } 1054 + if (activeImports > 0) { 1055 + parts.push(`${activeImports} import${activeImports === 1 ? '' : 's'} active`); 1056 + } 1057 + if (state.crashed.size > 0) { 1058 + parts.push(`${state.crashed.size} service${state.crashed.size === 1 ? '' : 's'} crashed`); 1059 + } 1060 + if (staleCount > 0) { 1061 + parts.push(`${staleCount} stale heartbeat${staleCount === 1 ? '' : 's'}`); 1062 + } 1063 + if (state.recentErrors.length > 0) { 1064 + parts.push(`${state.recentErrors.length} recent error${state.recentErrors.length === 1 ? '' : 's'}`); 1065 + } 1066 + 1067 + if (parts.length === 0) { 1068 + elements.statusSummaryText.textContent = servicesKnown ? 'Healthy — all quiet' : 'Waiting for status...'; 1069 + return; 1070 + } 1071 + 1072 + if (servicesKnown && state.crashed.size === 0 && staleCount === 0 && state.recentErrors.length === 0) { 1073 + parts.push('Healthy'); 1074 + } 1075 + 1076 + elements.statusSummaryText.textContent = parts.join(' · '); 1077 + } 1078 + 1079 + function updateAllQuiet() { 1080 + const allHidden = elements.cortexSection.classList.contains('hidden') && 1081 + elements.importerSection.classList.contains('hidden') && 1082 + elements.dreamCard.classList.contains('hidden') && 1083 + elements.syncCard.classList.contains('hidden'); 1084 + elements.allQuietCard.classList.toggle('hidden', !allHidden); 1085 + } 1086 + 1087 + function updateErrorSummary() { 1088 + if (state.recentErrors.length === 0) { 1089 + elements.errorSummary.classList.add('hidden'); 1090 + return; 1091 + } 1092 + 1093 + elements.errorSummary.classList.remove('hidden'); 1094 + const recent = state.recentErrors.slice(-10).reverse(); 1095 + elements.errorSummaryContent.innerHTML = recent.map(e => { 1096 + const icon = e.type === 'agent' ? '⚙' : '↓'; 1097 + const ago = formatElapsed(Math.floor((Date.now() - e.ts) / 1000)); 1098 + return `<div style="padding: 0.3em 0; font-size: 0.85em; color: #374151; border-bottom: 1px solid #f3f4f6;"> 1099 + ${icon} <strong>${escapeHtml(e.name)}</strong> — ${escapeHtml(truncate(e.error, 60))} <span style="color: #9ca3af; font-size: 0.85em;">${escapeHtml(ago)} ago</span> 1100 + </div>`; 1101 + }).join(''); 1102 + } 1103 + 935 1104 // Client-side elapsed time updater 936 1105 let elapsedTimer = null; 937 1106 function startElapsedTimer() { ··· 1040 1209 } else { 1041 1210 elements.schedulesValue.textContent = '—'; 1042 1211 } 1212 + 1213 + updateStatusSummary(); 1043 1214 } 1044 1215 1045 1216 function updateVitalsStatus(status) { ··· 1099 1270 elements.observeEmpty.classList.remove('hidden'); 1100 1271 elements.observeContent.classList.add('hidden'); 1101 1272 updateObserveMode(); 1273 + updateStatusSummary(); 1102 1274 return; 1103 1275 } 1104 1276 elements.observeEmpty.classList.add('hidden'); ··· 1122 1294 elements.describeDetail.textContent = ''; 1123 1295 elements.transcribeStatus.textContent = 'Waiting...'; 1124 1296 elements.transcribeDetail.textContent = ''; 1297 + updateStatusSummary(); 1125 1298 return; 1126 1299 } 1127 1300 ··· 1241 1414 elements.transcribeStatus.textContent = 'Idle'; 1242 1415 elements.transcribeDetail.textContent = ''; 1243 1416 } 1417 + 1418 + updateStatusSummary(); 1244 1419 } 1245 1420 1246 1421 // Update observers ··· 1317 1492 1318 1493 if (activeAgents.length === 0) { 1319 1494 elements.cortexSection.classList.add('hidden'); 1495 + updateAllQuiet(); 1496 + updateStatusSummary(); 1320 1497 return; 1321 1498 } 1322 1499 ··· 1339 1516 }).join(''); 1340 1517 1341 1518 elements.cortexGrid.innerHTML = html; 1519 + updateAllQuiet(); 1520 + updateStatusSummary(); 1342 1521 } 1343 1522 1344 1523 // Update importer grid ··· 1347 1526 1348 1527 if (activeImports.length === 0) { 1349 1528 elements.importerSection.classList.add('hidden'); 1529 + updateAllQuiet(); 1530 + updateStatusSummary(); 1350 1531 return; 1351 1532 } 1352 1533 ··· 1381 1562 }).join(''); 1382 1563 1383 1564 elements.importerGrid.innerHTML = html; 1565 + updateAllQuiet(); 1566 + updateStatusSummary(); 1384 1567 } 1385 1568 1386 1569 function handleDreamEvent(msg) { ··· 1402 1585 function updateDreamCard() { 1403 1586 if (!state.dreamActive || !state.dream) { 1404 1587 elements.dreamCard.classList.add('hidden'); 1588 + updateAllQuiet(); 1589 + updateStatusSummary(); 1405 1590 return; 1406 1591 } 1407 1592 ··· 1442 1627 } else { 1443 1628 elements.dreamAgents.textContent = ''; 1444 1629 } 1630 + 1631 + updateAllQuiet(); 1632 + updateStatusSummary(); 1445 1633 } 1446 1634 1447 1635 function handleSyncEvent(msg) { ··· 1456 1644 // Show when there's queued work or active upload 1457 1645 if (!s || (s.queue_size === 0 && !s.segment)) { 1458 1646 elements.syncCard.classList.add('hidden'); 1647 + updateAllQuiet(); 1648 + updateStatusSummary(); 1459 1649 return; 1460 1650 } 1461 1651 ··· 1468 1658 if (s.state) infoParts.push(`<div class="sync-info-item"><div class="sync-info-label">State</div><div>${escapeHtml(s.state)}${s.confirm_attempt ? ' (' + escapeHtml(s.confirm_attempt) + ')' : ''}</div></div>`); 1469 1659 if (s.segment) infoParts.push(`<div class="sync-info-item"><div class="sync-info-label">Segment</div><div>${escapeHtml(s.segment)}</div></div>`); 1470 1660 elements.syncInfo.innerHTML = infoParts.join(''); 1661 + 1662 + updateAllQuiet(); 1663 + updateStatusSummary(); 1471 1664 } 1472 1665 1473 1666 const LOG_BUFFER_SIZE = 50; ··· 1495 1688 message: record.line.replace(/^\S+\s+\[\S+\]\s+/, '').slice(0, 120), 1496 1689 ts: record.ts 1497 1690 }); 1691 + state.logErrorCount++; 1692 + elements.logErrorBadge.textContent = state.logErrorCount + ' error' + (state.logErrorCount > 1 ? 's' : ''); 1693 + elements.logErrorBadge.classList.remove('hidden'); 1694 + if (state.logsCollapsed) { 1695 + state.logsCollapsed = false; 1696 + document.querySelector('.logs-card').classList.remove('logs-collapsed'); 1697 + elements.logsCollapseIndicator.textContent = '▼ Hide'; 1698 + renderLogs(); 1699 + state.lastLogFilter = null; 1700 + } 1498 1701 } 1499 1702 1500 1703 // Update service filter dropdown if new service ··· 1665 1868 1666 1869 // Remove finished/error agents after delay 1667 1870 if (msg.event === 'finish' || msg.event === 'error') { 1871 + if (msg.event === 'error') { 1872 + state.recentErrors.push({ 1873 + type: 'agent', 1874 + id: agentId, 1875 + name: msg.name || existing.name || 'unknown', 1876 + error: msg.error || 'Unknown error', 1877 + ts: Date.now() 1878 + }); 1879 + if (state.recentErrors.length > 50) state.recentErrors.shift(); 1880 + updateErrorSummary(); 1881 + } 1668 1882 setTimeout(() => { 1669 1883 state.agents.delete(agentId); 1670 1884 updateCortexGrid(); ··· 1726 1940 lastSeen: Date.now() 1727 1941 }); 1728 1942 1943 + state.recentErrors.push({ 1944 + type: 'import', 1945 + id: importId, 1946 + name: msg.input_file || existing.input_file || 'unknown', 1947 + error: msg.error || 'Unknown error', 1948 + ts: Date.now() 1949 + }); 1950 + if (state.recentErrors.length > 50) state.recentErrors.shift(); 1951 + updateErrorSummary(); 1952 + 1729 1953 // Keep error visible longer 1730 1954 setTimeout(() => { 1731 1955 state.imports.delete(importId); ··· 1752 1976 // Log controls 1753 1977 elements.logServiceFilter.addEventListener('change', renderLogs); 1754 1978 elements.logStreamFilter.addEventListener('change', renderLogs); 1979 + document.querySelector('.logs-header').addEventListener('click', (e) => { 1980 + if (state.deepLinkMode || e.target.closest('.logs-controls')) return; 1981 + state.logsCollapsed = !state.logsCollapsed; 1982 + const card = document.querySelector('.logs-card'); 1983 + card.classList.toggle('logs-collapsed', state.logsCollapsed); 1984 + elements.logsCollapseIndicator.textContent = state.logsCollapsed ? '▶ Show' : '▼ Hide'; 1985 + if (!state.logsCollapsed) renderLogs(); 1986 + }); 1755 1987 elements.logFollowBtn.addEventListener('click', () => { 1756 1988 state.logFollow = !state.logFollow; 1757 1989 elements.logFollowBtn.classList.toggle('active', state.logFollow); ··· 1768 2000 // Deep-link: display log file content if ?log= param is present 1769 2001 const deepLinkLog = new URLSearchParams(window.location.search).get('log'); 1770 2002 if (deepLinkLog) { 2003 + state.logsCollapsed = false; 2004 + document.querySelector('.logs-card').classList.remove('logs-collapsed'); 2005 + elements.logsCollapseIndicator.textContent = '▼ Hide'; 1771 2006 const viewport = elements.logsViewport; 1772 2007 const logsCard = viewport.closest('.logs-card'); 1773 2008 const logsTitle = logsCard.querySelector('.logs-title');