From afeb7b095a27d9041dafa1ae11bb858dbe844266 Mon Sep 17 00:00:00 2001 From: Andrew Bork Date: Tue, 31 Mar 2026 06:13:19 -0700 Subject: [PATCH] Merge upstream + local improvements: agent status mapping, parallel dashboard loading, rig parsing, bd exec fixes - Richer agent status: proper state mapping, rig agent distinction, current_task tracking - Dashboard: parallel data loading via Promise.allSettled for status/convoys/work/mail - Server: robust rig list parsing with JSON fallback to text parsing - BDGateway: use 'bd' executable, keep --no-daemon compat, use 'create' command - Various UI/component improvements across sidebar, agent-grid, work-list, etc. --- css/components.css | 99 +++++++++++- css/layout.css | 11 ++ index.html | 20 +-- js/app.js | 268 +++++++++---------------------- js/components/agent-grid.js | 10 +- js/components/convoy-list.js | 3 +- js/components/dashboard.js | 73 +++++---- js/components/issue-list.js | 49 +++++- js/components/mail-list.js | 132 ++++++++++++++- js/components/pr-list.js | 51 +++++- js/components/rig-list.js | 19 ++- js/components/sidebar.js | 12 +- js/components/work-list.js | 6 +- js/shared/beads.js | 10 ++ js/shared/github-repos.js | 14 ++ js/state.js | 6 + package-lock.json | 7 +- server.js | 90 +++++++---- server/gateways/BDGateway.js | 21 ++- server/routes/beads.js | 5 +- server/services/StatusService.js | 7 + 21 files changed, 603 insertions(+), 310 deletions(-) diff --git a/css/components.css b/css/components.css index 84d612f..aee9e0c 100644 --- a/css/components.css +++ b/css/components.css @@ -587,6 +587,10 @@ select { display: flex; flex-direction: column; gap: var(--space-md); + flex: 1; + min-height: 0; + overflow-y: auto; + padding: var(--space-lg); } .rig-card { @@ -777,6 +781,9 @@ select { /* === Crew Card === */ .crew-list { + flex: 1; + min-height: 0; + overflow-y: auto; padding: var(--space-lg); } @@ -1032,6 +1039,18 @@ select { text-overflow: ellipsis; } +.mail-subject-row { + display: flex; + align-items: center; + gap: var(--space-sm); + justify-content: space-between; + margin-bottom: var(--space-xxs); +} + +.mail-subject-row .mail-subject { + margin-bottom: 0; +} + /* === Mail Filters === */ .mail-filters { padding: var(--space-md); @@ -1097,6 +1116,54 @@ select { border-top: 1px solid var(--border-default); } +.mail-summary { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); + margin-bottom: var(--space-md); +} + +.mail-summary-chip, +.mail-signal-chip { + display: inline-flex; + align-items: center; + gap: var(--space-xxs); + padding: 4px 10px; + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: 600; +} + +.mail-summary-chip .material-icons, +.mail-signal-chip .material-icons { + font-size: 14px; +} + +.mail-summary-chip.total { + background: var(--bg-secondary); + border: 1px solid var(--border-default); + color: var(--text-secondary); +} + +.mail-summary-chip.action, +.mail-summary-chip.critical, +.mail-signal-chip.tone-critical { + background: rgba(239, 68, 68, 0.12); + color: #ef4444; +} + +.mail-summary-chip.warning, +.mail-signal-chip.tone-warning { + background: rgba(245, 158, 11, 0.12); + color: #f59e0b; +} + +.mail-summary-chip.info, +.mail-signal-chip.tone-info { + background: rgba(59, 130, 246, 0.12); + color: #3b82f6; +} + .legend-item { display: inline-flex; align-items: center; @@ -1138,6 +1205,18 @@ select { border-left: 3px solid var(--from-color, var(--border-default)); } +.mail-item.feed-mail.tone-critical { + border-left-color: #ef4444; +} + +.mail-item.feed-mail.tone-warning { + border-left-color: #f59e0b; +} + +.mail-item.feed-mail.tone-info { + border-left-color: #3b82f6; +} + .mail-item .mail-status { display: flex; align-items: center; @@ -3848,6 +3927,8 @@ select { display: flex; flex-direction: column; gap: var(--space-md); + flex: 1; + min-height: 0; padding: var(--space-lg); overflow-y: auto; } @@ -4503,6 +4584,9 @@ select { display: flex; flex-direction: column; gap: var(--space-sm); + flex: 1; + min-height: 0; + overflow-y: auto; padding: var(--space-md); } @@ -4677,6 +4761,10 @@ select { width: 100%; } +.non-github-state .empty-state-actions { + margin-top: var(--space-md); +} + .pr-list .loading-state .loading-spinner { width: 48px; height: 48px; @@ -4775,6 +4863,10 @@ select { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: var(--space-md); + flex: 1; + min-height: 0; + overflow-y: auto; + align-content: start; padding: var(--space-md); } @@ -5032,6 +5124,9 @@ select { display: flex; flex-direction: column; gap: var(--space-sm); + flex: 1; + min-height: 0; + overflow-y: auto; padding: var(--space-md); } @@ -5424,7 +5519,7 @@ select { flex: 1; display: flex; flex-direction: column; - overflow: hidden; + overflow-y: auto; min-height: 0; /* Allow flex shrinking */ } @@ -5773,7 +5868,7 @@ select { padding: var(--space-lg); flex: 1; min-height: 0; - overflow: hidden; + overflow-y: auto; } /* Loading Skeleton */ diff --git a/css/layout.css b/css/layout.css index 22dc3f9..c0222a8 100644 --- a/css/layout.css +++ b/css/layout.css @@ -444,6 +444,7 @@ body { display: flex; flex: 1; overflow: hidden; + min-height: 0; } /* === Sidebar === */ @@ -455,6 +456,8 @@ body { display: flex; flex-direction: column; transition: width var(--transition-base), min-width var(--transition-base); + min-height: 0; + overflow: hidden; } .sidebar.collapsed { @@ -481,6 +484,7 @@ body { .agent-tree { flex: 1; + min-height: 0; overflow-y: auto; padding: var(--space-sm); } @@ -492,6 +496,7 @@ body { flex-direction: column; overflow: hidden; background: var(--bg-primary); + min-height: 0; } .view { @@ -499,6 +504,7 @@ body { flex-direction: column; flex: 1; overflow: hidden; + min-height: 0; } .view.active { @@ -511,6 +517,7 @@ body { justify-content: space-between; padding: var(--space-md) var(--space-lg); border-bottom: 1px solid var(--border-muted); + flex-shrink: 0; } .view-header h1 { @@ -547,6 +554,8 @@ body { border-left: 1px solid var(--border-default); display: flex; flex-direction: column; + min-height: 0; + overflow: hidden; } .feed-header { @@ -567,6 +576,7 @@ body { .feed-list { flex: 1; + min-height: 0; overflow-y: auto; padding: var(--space-sm); } @@ -582,6 +592,7 @@ body { border-top: 1px solid var(--border-default); font-size: var(--text-xs); color: var(--text-secondary); + flex-shrink: 0; } .status-left, diff --git a/index.html b/index.html index ba92d12..dc4e343 100644 --- a/index.html +++ b/index.html @@ -75,7 +75,7 @@ groups Agents - @@ -96,11 +96,11 @@ Formulas - - @@ -205,13 +205,13 @@

Active Convoys

All Work

- -
@@ -320,10 +320,10 @@

Formulas

- +
-

GitHub Issues

+

Issues

@@ -343,14 +343,14 @@

GitHub Issues

-

My Inbox

+

All Mail

- - diff --git a/js/app.js b/js/app.js index 9da2d7f..e52f679 100644 --- a/js/app.js +++ b/js/app.js @@ -44,8 +44,6 @@ const REFRESH_TOAST_DURATION_MS = 1000; const MAYOR_OUTPUT_POLL_INTERVAL_MS = 2000; const MAYOR_OUTPUT_TAIL_LINES = 80; const MAYOR_MESSAGE_PREVIEW_LENGTH = 40; -const REALTIME_REFRESH_DEBOUNCE_MS = 250; -const FOCUS_REFRESH_COOLDOWN_MS = 1500; // DOM Elements const elements = { @@ -66,10 +64,6 @@ const elements = { // Initialization guard to prevent double-init let isInitialized = false; -let realtimeRefreshTimer = null; -let realtimeRefreshInFlight = false; -let queuedRealtimeRefreshReason = null; -let lastWindowActiveRefreshAt = 0; // Loading state helpers function showLoadingState(container, message = 'Loading...') { @@ -151,31 +145,39 @@ async function init() { // Check for first-time users - show onboarding wizard const showOnboarding = await shouldShowOnboarding(); + const hasExistingTownActivity = Boolean( + state.get('status')?.rigs?.length || + state.get('convoys')?.length || + state.get('work')?.length || + state.get('agents')?.length + ); if (showOnboarding) { setTimeout(() => startOnboarding(), ONBOARDING_START_DELAY_MS); - } else if (shouldShowTutorial()) { + } else if (!hasExistingTownActivity && shouldShowTutorial()) { // Show tutorial only if onboarding was already completed setTimeout(() => startTutorial(), TUTORIAL_START_DELAY_MS); + } else if (hasExistingTownActivity) { + localStorage.setItem('gastown-tutorial-complete', 'true'); } // Listen for onboarding completion document.addEventListener(ONBOARDING_COMPLETE, () => { - loadInitialData({ forceRefresh: true }); + loadInitialData(); }); // Listen for status refresh (from service controls) document.addEventListener(STATUS_REFRESH, () => { - loadInitialData({ forceRefresh: true }); + loadInitialData(); }); // Listen for dashboard refresh document.addEventListener(DASHBOARD_REFRESH, () => { - loadDashboard({ forceRefresh: true }); + loadDashboard(); }); // Listen for rigs refresh (from agent controls) document.addEventListener(RIGS_REFRESH, () => { - loadRigs({ forceRefresh: true }); + loadRigs(); }); // Listen for work refresh (from work actions) @@ -185,15 +187,7 @@ async function init() { // Listen for mail refresh (from read/unread actions) document.addEventListener(MAIL_REFRESH, () => { - loadMail({ forceRefresh: true }); - }); - - // Refresh immediately when the window/tab becomes active again - window.addEventListener('focus', refreshOnWindowActive); - document.addEventListener('visibilitychange', () => { - if (!document.hidden) { - refreshOnWindowActive(); - } + loadMail(); }); // Handle mail detail modal @@ -310,74 +304,6 @@ function switchView(viewId) { } } -function getActiveViewId() { - const activeView = document.querySelector('.view.active'); - if (!activeView?.id) return 'dashboard'; - return activeView.id.replace(/^view-/, ''); -} - -async function refreshActiveView({ forceRefresh = false } = {}) { - const activeView = getActiveViewId(); - - if (activeView === 'agents') { - await loadAgents({ forceRefresh }); - return; - } - if (activeView === 'rigs') { - await loadRigs({ forceRefresh }); - return; - } - if (activeView === 'mail') { - await loadMail({ forceRefresh }); - return; - } - if (activeView === 'work') { - await loadWork(); - } -} - -function scheduleRealtimeRefresh(reason, { immediate = false } = {}) { - if (realtimeRefreshTimer || realtimeRefreshInFlight) { - queuedRealtimeRefreshReason = reason; - return; - } - - const delay = immediate ? 0 : REALTIME_REFRESH_DEBOUNCE_MS; - realtimeRefreshTimer = setTimeout(async () => { - realtimeRefreshTimer = null; - realtimeRefreshInFlight = true; - - try { - await loadInitialData({ - forceRefresh: true, - silent: true, - includeMayorHistory: false, - preloadBackground: false, - }); - await refreshActiveView({ forceRefresh: true }); - console.log(`[App] Applied realtime refresh (${reason})`); - } catch (err) { - console.error('[App] Realtime refresh failed:', err); - } finally { - realtimeRefreshInFlight = false; - if (queuedRealtimeRefreshReason) { - const nextReason = queuedRealtimeRefreshReason; - queuedRealtimeRefreshReason = null; - scheduleRealtimeRefresh(`${nextReason}_queued`, { immediate: true }); - } - } - }, delay); -} - -function refreshOnWindowActive() { - const now = Date.now(); - if (now - lastWindowActiveRefreshAt < FOCUS_REFRESH_COOLDOWN_MS) { - return; - } - lastWindowActiveRefreshAt = now; - scheduleRealtimeRefresh('window_active', { immediate: true }); -} - // WebSocket connection function connectWebSocket() { updateConnectionStatus('connecting'); @@ -423,35 +349,29 @@ function handleWebSocketMessage(message) { case 'convoy_created': case 'convoy_updated': - if (message.data?.id) { - state.updateConvoy(message.data); - } - scheduleRealtimeRefresh(message.type); + state.updateConvoy(message.data); break; case 'work_slung': showToast(`Work slung: ${message.data?.bead || 'unknown'}`, 'success'); - scheduleRealtimeRefresh(message.type); + loadConvoys(); break; case 'bead_created': // Bead was created - refresh work list if visible - if (getActiveViewId() === 'work') { + if (state.currentView === 'work') { loadWork(); } showToast('Work item created', 'success'); - scheduleRealtimeRefresh(message.type); break; case 'rig_added': // Rig was added - refresh rigs list and status showToast(`Rig added: ${message.data?.name || 'unknown'}`, 'success'); - scheduleRealtimeRefresh(message.type); - break; - - case 'rig_removed': - showToast(`Rig removed: ${message.data?.name || 'unknown'}`, 'info'); - scheduleRealtimeRefresh(message.type); + api.getStatus(true); // Force refresh + if (state.currentView === 'rigs') { + loadRigs(); + } break; case 'mayor_message': @@ -479,22 +399,8 @@ function handleWebSocketMessage(message) { service: message.data.service }); } - scheduleRealtimeRefresh(message.type); - break; - - case 'service_stopped': - case 'service_restarted': - case 'agent_started': - case 'agent_stopped': - case 'agent_restarted': - case 'crew_added': - case 'crew_removed': - case 'work_done': - case 'work_parked': - case 'work_released': - case 'work_reassigned': - case 'escalation': - scheduleRealtimeRefresh(message.type); + // Refresh status and update state to re-render sidebar + api.getStatus().then(status => state.setStatus(status)).catch(console.error); break; default: @@ -517,36 +423,24 @@ function updateConnectionStatus(status) { } // Data loading -async function loadInitialData({ - forceRefresh = false, - silent = false, - includeMayorHistory = true, - preloadBackground = true, -} = {}) { - if (!silent) { - elements.statusMessage.textContent = 'Loading...'; - } +async function loadInitialData() { + elements.statusMessage.textContent = 'Loading...'; try { // Load all critical data in parallel using Promise.allSettled // This way a slow/failing request doesn't block others - const tasks = [ - api.getStatus(forceRefresh).then(status => { + const results = await Promise.allSettled([ + api.getStatus().then(status => { state.setStatus(status); return status; }), - loadConvoys({ forceRefresh }), - ]; - const labels = ['status', 'convoys']; - - if (includeMayorHistory) { - tasks.push(loadMayorMessageHistory()); - labels.push('mayor history'); - } - - const results = await Promise.allSettled(tasks); + loadConvoys(), + loadMayorMessageHistory(), + loadDashboard(), + ]); // Check results and log any failures + const labels = ['status', 'convoys', 'mayor history', 'dashboard']; results.forEach((result, i) => { if (result.status === 'rejected') { console.error(`[App] Failed to load ${labels[i]}:`, result.reason); @@ -554,32 +448,19 @@ async function loadInitialData({ }); // If status failed, show warning - if (!silent) { - if (results[0].status === 'rejected') { - elements.statusMessage.textContent = 'Ready (status unavailable)'; - showToast('Some data failed to load', 'warning'); - } else { - elements.statusMessage.textContent = 'Ready'; - } + if (results[0].status === 'rejected') { + elements.statusMessage.textContent = 'Ready (status unavailable)'; + showToast('Some data failed to load', 'warning'); + } else { + elements.statusMessage.textContent = 'Ready'; } - // Reuse status from initial fetch so dashboard refresh doesn't issue - // a second status request on every realtime mutation. - const dashboardStatus = results[0].status === 'fulfilled' - ? results[0].value - : state.get('status') || null; - await loadDashboard({ forceRefresh, statusOverride: dashboardStatus }); - // Background preload of other data (don't await, let it load in background) - if (preloadBackground) { - preloadBackgroundData(); - } + preloadBackgroundData(); } catch (err) { console.error('[App] Failed to load initial data:', err); - if (!silent) { - elements.statusMessage.textContent = 'Cannot connect to server'; - showToast('Cannot connect - is the server running? Check terminal for the correct URL.', 'error', CONNECTION_ERROR_TOAST_DURATION_MS); - } + elements.statusMessage.textContent = 'Cannot connect to server'; + showToast('Cannot connect - is the server running? Check terminal for the correct URL.', 'error', CONNECTION_ERROR_TOAST_DURATION_MS); } } @@ -610,13 +491,10 @@ async function preloadBackgroundData() { // Track convoy filter state let showAllConvoys = false; -async function loadConvoys({ forceRefresh = false } = {}) { +async function loadConvoys() { showLoadingState(elements.convoyList, 'Loading convoys...'); try { const params = showAllConvoys ? { all: 'true' } : {}; - if (forceRefresh) { - params.refresh = 'true'; - } const convoys = await api.getConvoys(params); state.setConvoys(convoys); } catch (err) { @@ -684,20 +562,19 @@ function setupConvoyFilters() { } // Track mail filter state -let mailFilter = 'mine'; // 'mine' = my inbox, 'all' = all system mail +let mailFilter = 'all'; // 'mine' = my inbox, 'all' = all system mail -async function loadMail({ forceRefresh = false } = {}) { +async function loadMail() { showLoadingState(elements.mailList, 'Loading mail...'); try { let mail; if (mailFilter === 'all') { // Get all mail from feed (paginated response) - const endpoint = forceRefresh ? '/api/mail/all?refresh=true' : '/api/mail/all'; - const response = await api.get(endpoint); + const response = await api.get('/api/mail/all'); mail = response.items || response; // Handle both paginated and legacy responses } else { // Get my inbox only - mail = await api.getMail(forceRefresh); + mail = await api.getMail(); } state.setMail(mail || []); } catch (err) { @@ -740,7 +617,7 @@ function setupMailFilters() { } } -async function loadAgents({ forceRefresh = false } = {}) { +async function loadAgents() { // Show loading state only if we don't have cached data const hasCache = state.getAgents().length > 0; if (!hasCache) { @@ -748,16 +625,20 @@ async function loadAgents({ forceRefresh = false } = {}) { } try { - const response = await api.getAgents(forceRefresh); - // Combine agents and polecats into a flat list - const allAgents = [ - ...(response.agents || []), - ...(response.polecats || []).map(p => ({ - ...p, - id: p.name, - status: p.running ? 'working' : 'idle', - })), - ]; + const response = await api.getAgents(); + const townAgents = (response.agents || []).map(agent => ({ + ...agent, + id: agent.id || agent.address || agent.name, + status: agent.state || (agent.running ? (agent.has_work ? 'working' : 'running') : 'idle'), + current_task: agent.work_title || agent.current_task || null, + })); + const rigAgents = (response.rigAgents || []).map(agent => ({ + ...agent, + id: agent.id || agent.address || `${agent.rig}/${agent.name}`, + status: agent.state || (agent.running ? (agent.has_work || agent.hook_bead ? 'working' : 'running') : 'idle'), + current_task: agent.work_title || agent.current_task || agent.hook_bead || null, + })); + const allAgents = [...townAgents, ...rigAgents]; state.setAgents(allAgents); } catch (err) { console.error('[App] Failed to load agents:', err); @@ -773,7 +654,7 @@ async function loadAgents({ forceRefresh = false } = {}) { } } -async function loadRigs({ forceRefresh = false } = {}) { +async function loadRigs() { // Show loading state only if we don't have cached data const hasCache = state.getRigs().length > 0; if (!hasCache) { @@ -785,7 +666,7 @@ async function loadRigs({ forceRefresh = false } = {}) { try { // Get rigs from status (has more details than /api/rigs) - const status = await api.getStatus(forceRefresh); + const status = await api.getStatus(); const rigs = status.rigs || []; state.setStatus(status); // Update state renderRigList(elements.rigList, rigs); @@ -804,14 +685,14 @@ async function loadRigs({ forceRefresh = false } = {}) { } // Track work filter state -let workFilter = 'closed'; // Default to showing completed work +let workFilter = 'all'; async function loadWork() { showLoadingState(elements.workList, 'Loading work...'); try { - const params = workFilter === 'all' ? {} : { status: workFilter }; - const beads = await api.get(`/api/beads${workFilter !== 'all' ? `?status=${workFilter}` : ''}`); - renderWorkList(elements.workList, beads || []); + const query = workFilter !== 'all' ? `?status=${encodeURIComponent(workFilter)}` : ''; + const beads = await api.get(`/api/beads${query}`); + state.setWork(beads || []); } catch (err) { console.error('[App] Failed to load work:', err); elements.workList.innerHTML = ` @@ -885,6 +766,10 @@ function subscribeToState() { renderConvoyList(elements.convoyList, convoys); }); + subscribe('work', (work) => { + renderWorkList(elements.workList, work); + }); + // Agent updates subscribe('agents', (agents) => { renderAgentGrid(elements.agentGrid, agents); @@ -901,13 +786,14 @@ function subscribeToState() { // Update badge const unread = mail.filter(m => !m.read).length; + const badgeCount = mailFilter === 'all' ? mail.length : unread; if (elements.mailBadge) { - elements.mailBadge.textContent = unread; - elements.mailBadge.classList.toggle('hidden', unread === 0); + elements.mailBadge.textContent = badgeCount; + elements.mailBadge.classList.toggle('hidden', badgeCount === 0); } if (elements.moreBadge) { - elements.moreBadge.textContent = unread; - elements.moreBadge.classList.toggle('hidden', unread === 0); + elements.moreBadge.textContent = badgeCount; + elements.moreBadge.classList.toggle('hidden', badgeCount === 0); } }); } @@ -986,7 +872,7 @@ function setupKeyboardShortcuts() { break; case 'r': e.preventDefault(); - loadInitialData({ forceRefresh: true }); + loadInitialData(); showToast('Refreshing...', 'info', REFRESH_TOAST_DURATION_MS); break; case 's': @@ -1137,7 +1023,7 @@ function setupThemeToggle() { // Refresh button document.getElementById('refresh-btn').addEventListener('click', () => { - loadInitialData({ forceRefresh: true }); + loadInitialData(); showToast('Refreshing...', 'info', REFRESH_TOAST_DURATION_MS); }); diff --git a/js/components/agent-grid.js b/js/components/agent-grid.js index 320d4b8..80a305e 100644 --- a/js/components/agent-grid.js +++ b/js/components/agent-grid.js @@ -84,6 +84,14 @@ export function renderAgentGrid(container, agents) { showAgentOutput(agentId); }); } + + const viewBtn = card.querySelector('[data-action="view"]'); + if (viewBtn) { + viewBtn.addEventListener('click', (e) => { + e.stopPropagation(); + showAgentDetail(agentId); + }); + } }); } @@ -93,7 +101,7 @@ export function renderAgentGrid(container, agents) { function renderAgentCard(agent, index) { const role = agent.role?.toLowerCase() || 'polecat'; const agentConfig = getAgentConfig(agent.address || agent.id, role); - const status = agent.running ? 'running' : (agent.status || 'idle'); + const status = agent.status || (agent.running ? (agent.has_work || agent.current_task ? 'working' : 'running') : 'idle'); const statusIcon = STATUS_ICONS[status] || STATUS_ICONS.idle; const statusColor = STATUS_COLORS[status] || STATUS_COLORS.idle; diff --git a/js/components/convoy-list.js b/js/components/convoy-list.js index 53dd576..e621a62 100644 --- a/js/components/convoy-list.js +++ b/js/components/convoy-list.js @@ -219,6 +219,7 @@ function renderConvoyCard(convoy, index) { const priorityClass = PRIORITY_CLASSES[convoy.priority] || ''; const progress = calculateProgress(convoy); const isExpanded = expandedConvoys.has(convoy.id); + const convoyName = convoy.title || convoy.name || convoy.id; return `
${statusIcon}
-

${escapeHtml(convoy.name || convoy.id)}

+

${escapeHtml(convoyName)}

#${convoy.id?.slice(0, 8) || 'unknown'} ${convoy.priority ? `${convoy.priority}` : ''} diff --git a/js/components/dashboard.js b/js/components/dashboard.js index 3a7ea7c..e28d506 100644 --- a/js/components/dashboard.js +++ b/js/components/dashboard.js @@ -9,6 +9,7 @@ import { api } from '../api.js'; import { state } from '../state.js'; import { showToast } from './toast.js'; import { AGENT_TYPES, STATUS_COLORS, getAgentConfig } from '../shared/agent-types.js'; +import { isUserVisibleWorkBead } from '../shared/beads.js'; import { BEAD_DETAIL, STATUS_UPDATED } from '../shared/events.js'; import { escapeHtml } from '../utils/html.js'; import { formatTimeAgoCompact } from '../utils/formatting.js'; @@ -105,30 +106,36 @@ export function initDashboard() { refreshBtn = document.getElementById('dashboard-refresh'); if (refreshBtn) { - refreshBtn.addEventListener('click', () => loadDashboard({ forceRefresh: true })); + refreshBtn.addEventListener('click', loadDashboard); } // Listen for status updates - document.addEventListener(STATUS_UPDATED, () => loadDashboard({ forceRefresh: true })); + document.addEventListener(STATUS_UPDATED, loadDashboard); } /** * Load and render dashboard */ -export async function loadDashboard({ forceRefresh = false, statusOverride = null } = {}) { +export async function loadDashboard() { if (!container) return; // Show loading skeleton container.innerHTML = renderLoadingSkeleton(); try { - // Only load status - doctor is too slow for dashboard (15-20s) - // User can click through to Health page for full diagnostics - const status = statusOverride - || await api.getStatus(forceRefresh).catch(() => null) - || state.get('status') - || {}; - // Derive basic health from status data (fast, no doctor call) + const [statusResult, convoyResult, workResult, mailResult] = await Promise.allSettled([ + api.getStatus(), + api.getConvoys({ all: 'true' }), + api.get('/api/beads'), + api.get('/api/mail/all?limit=200'), + ]); + + const status = statusResult.status === 'fulfilled' ? statusResult.value : (state.get('status') || {}); + if (statusResult.status === 'fulfilled') state.setStatus(statusResult.value); + if (convoyResult.status === 'fulfilled') state.setConvoys(convoyResult.value || []); + if (workResult.status === 'fulfilled') state.setWork(workResult.value || []); + if (mailResult.status === 'fulfilled') state.setMail(mailResult.value?.items || mailResult.value || []); + const health = deriveHealthFromStatus(status); renderDashboard(status, health); @@ -180,7 +187,10 @@ function renderDashboard(status, health) { const rigs = status.rigs || []; const convoys = state.get('convoys') || []; const work = state.get('work') || []; - const agents = state.get('agents') || []; + const agents = [ + ...(status.agents || []), + ...rigs.flatMap(rig => rig.agents || []), + ]; const mail = state.get('mail') || []; const metrics = calculateMetrics(rigs, convoys, work, agents, mail); @@ -195,7 +205,7 @@ function renderDashboard(status, health) { ${renderMetricCard('local_shipping', 'Active Convoys', metrics.activeConvoys, metrics.totalConvoys, 'convoys', '#3b82f6')} ${renderMetricCard('task_alt', 'Open Work', metrics.openWork, metrics.totalWork, 'work', '#22c55e')} ${renderAgentMetricCard(metrics)} - ${renderMetricCard('mail', 'Unread Mail', metrics.unreadMail, metrics.totalMail, 'mail', '#f59e0b')} + ${renderMetricCard('mail', 'Mail Events', metrics.totalMail, null, 'mail', '#f59e0b')}
@@ -269,7 +279,7 @@ function renderGettingStarted() {
1
-
Connect a GitHub repo
+
Connect a repository
arrow_forward
@@ -295,21 +305,17 @@ function renderGettingStarted() { * Calculate dashboard metrics */ function calculateMetrics(rigs, convoys, work, agents, mail) { - const activeConvoys = convoys.filter(c => c.status !== 'completed' && c.status !== 'closed').length; - const openWork = work.filter(w => w.status !== 'closed' && w.status !== 'done').length; - const unreadMail = mail.filter(m => !m.read).length; - - // Collect all agents from rigs and get stats - const allAgents = rigs.flatMap(rig => rig.agents || []); - const agentStats = getAgentStats(allAgents); + const visibleWork = (work || []).filter(isUserVisibleWorkBead); + const activeConvoys = convoys.filter(c => !['completed', 'complete', 'closed'].includes(String(c.status || '').toLowerCase())).length; + const openWork = visibleWork.filter(w => !['closed', 'done', 'complete', 'completed'].includes(String(w.status || '').toLowerCase())).length; + const agentStats = getAgentStats(agents); return { activeConvoys, totalConvoys: convoys.length, openWork, - totalWork: work.length, + totalWork: visibleWork.length, ...agentStats, // working, available, stopped, total, statusText - unreadMail, totalMail: mail.length, }; } @@ -453,13 +459,16 @@ function renderAgentStatus(rigs, agents) { const agentsByType = {}; agentTypes.forEach(type => { agentsByType[type] = []; }); - rigs.forEach(rig => { - (rig.agents || []).forEach(agent => { - const type = (agent.role || 'polecat').toLowerCase(); - if (agentsByType[type]) { - agentsByType[type].push(agent); - } - }); + agents.forEach(agent => { + const apiRole = String(agent.role || '').toLowerCase(); + const type = apiRole === 'coordinator' + ? 'mayor' + : apiRole === 'health-check' + ? 'deacon' + : (apiRole || 'polecat'); + if (agentsByType[type]) { + agentsByType[type].push(agent); + } }); return ` @@ -511,7 +520,9 @@ function renderAgentStatus(rigs, agents) { * Render recent work */ function renderRecentWork(work) { - if (!work || work.length === 0) { + const visibleWork = (work || []).filter(isUserVisibleWorkBead); + + if (visibleWork.length === 0) { return `
inbox @@ -521,7 +532,7 @@ function renderRecentWork(work) { } // Show last 5 items sorted by date - const recent = [...work] + const recent = [...visibleWork] .sort((a, b) => new Date(b.updated_at || b.created_at || 0) - new Date(a.updated_at || a.created_at || 0)) .slice(0, 5); diff --git a/js/components/issue-list.js b/js/components/issue-list.js index 74cf60a..2ffbbd4 100644 --- a/js/components/issue-list.js +++ b/js/components/issue-list.js @@ -6,6 +6,8 @@ import { api } from '../api.js'; import { showToast } from './toast.js'; +import { state } from '../state.js'; +import { getGitHubBackedRigs } from '../shared/github-repos.js'; import { formatRelativeTime } from '../utils/formatting.js'; import { escapeHtml } from '../utils/html.js'; import { getStaggerClass } from '../shared/animations.js'; @@ -59,6 +61,12 @@ export async function loadIssues() { container.innerHTML = '
Loading issues...
'; try { + const gitHubRigs = await getAvailableGitHubRigs(); + if (gitHubRigs.length === 0) { + renderNonGitHubState(); + return; + } + issues = await api.getGitHubIssues(currentState); renderIssues(); } catch (err) { @@ -73,6 +81,44 @@ export async function loadIssues() { } } +async function getAvailableGitHubRigs() { + const cachedStatus = state.get('status'); + if (cachedStatus?.rigs?.length) { + return getGitHubBackedRigs(cachedStatus); + } + + const status = await api.getStatus(); + return getGitHubBackedRigs(status); +} + +function renderNonGitHubState() { + container.innerHTML = ` +
+
+ alt_route +
+

No issue tracker integration configured

+

This town currently exposes git remotes and worktrees only, so issue records are not available in this view.

+
+ + +
+
+ `; + + container.querySelectorAll('[data-navigate-view]').forEach(button => { + button.addEventListener('click', () => { + document.querySelector(`[data-view="${button.dataset.navigateView}"]`)?.click(); + }); + }); +} + /** * Render issue cards */ @@ -83,6 +129,7 @@ function renderIssues() { bug_report

No Issues

No ${currentState} issues found in connected repositories

+ No issue records were returned by the configured tracker integration.
`; return; @@ -145,7 +192,7 @@ function createIssueCard(issue, index) { - + open_in_new
diff --git a/js/components/mail-list.js b/js/components/mail-list.js index 23bef4b..c83ed71 100644 --- a/js/components/mail-list.js +++ b/js/components/mail-list.js @@ -68,9 +68,67 @@ function getUniqueRigs(mail) { let currentFilters = { agentType: 'all', rig: 'all', + category: 'all', search: '', }; +const SIGNAL_CONFIG = { + crash: { label: 'Crash', icon: 'warning', tone: 'critical', rank: 0 }, + escalation: { label: 'Escalation', icon: 'priority_high', tone: 'critical', rank: 0 }, + 'recovery-needed': { label: 'Recovery Needed', icon: 'build_circle', tone: 'critical', rank: 0 }, + 'recovery-update': { label: 'Recovery Update', icon: 'sync_problem', tone: 'warning', rank: 1 }, + delivery: { label: 'Delivery', icon: 'forward_to_inbox', tone: 'warning', rank: 1 }, + system: { label: 'System', icon: 'settings', tone: 'info', rank: 2 }, + note: { label: 'Note', icon: 'mail', tone: 'info', rank: 2 }, +}; + +function getMailSignal(mail) { + const subject = String(mail?.subject || '').toUpperCase(); + + if (subject.includes('CRASHED_POLECAT')) return { key: 'crash', ...SIGNAL_CONFIG.crash }; + if (subject.includes('ESCALATION')) return { key: 'escalation', ...SIGNAL_CONFIG.escalation }; + if (subject.includes('RECOVERY_NEEDED')) return { key: 'recovery-needed', ...SIGNAL_CONFIG['recovery-needed'] }; + if (subject.includes('RECOVERY_UPDATE')) return { key: 'recovery-update', ...SIGNAL_CONFIG['recovery-update'] }; + if (subject.includes('DELIVERY') || subject.includes('ACK')) return { key: 'delivery', ...SIGNAL_CONFIG.delivery }; + if (mail?.feedEvent) return { key: 'system', ...SIGNAL_CONFIG.system }; + return { key: 'note', ...SIGNAL_CONFIG.note }; +} + +function matchesSignalCategory(mail, category) { + if (category === 'all') return true; + + const signal = getMailSignal(mail); + if (category === 'action') { + return signal.tone === 'critical' || signal.tone === 'warning'; + } + + if (category === 'recovery') { + return signal.key === 'recovery-needed' || signal.key === 'recovery-update'; + } + + return signal.key === category; +} + +function summarizeSignals(mail) { + const summary = { + total: mail.length, + action: 0, + crash: 0, + recovery: 0, + escalation: 0, + }; + + mail.forEach(item => { + const signal = getMailSignal(item); + if (signal.tone === 'critical' || signal.tone === 'warning') summary.action += 1; + if (signal.key === 'crash') summary.crash += 1; + if (signal.key === 'escalation') summary.escalation += 1; + if (signal.key === 'recovery-needed' || signal.key === 'recovery-update') summary.recovery += 1; + }); + + return summary; +} + export function renderMailList(container, mail, options = {}) { if (!container) return; @@ -105,6 +163,9 @@ export function renderMailList(container, mail, options = {}) { m.to?.startsWith(currentFilters.rig) ); } + if (currentFilters.category !== 'all') { + filtered = filtered.filter(m => matchesSignalCategory(m, currentFilters.category)); + } if (currentFilters.search) { const searchLower = currentFilters.search.toLowerCase(); filtered = filtered.filter(m => @@ -118,6 +179,12 @@ export function renderMailList(container, mail, options = {}) { // Sort by date (newest first), then by read status const sorted = filtered.sort((a, b) => { + if (isAllMail) { + const signalDiff = getMailSignal(a).rank - getMailSignal(b).rank; + if (signalDiff !== 0) return signalDiff; + return new Date(b.timestamp || 0) - new Date(a.timestamp || 0); + } + // Unread first if (a.read !== b.read) return a.read ? 1 : -1; // Then by date @@ -125,6 +192,7 @@ export function renderMailList(container, mail, options = {}) { }); // Render + const summaryHtml = isAllMail ? renderMailSummary(filtered) : ''; const itemsHtml = sorted.length > 0 ? sorted.map((item, index) => renderMailItem(item, index)).join('') : `
@@ -132,7 +200,7 @@ export function renderMailList(container, mail, options = {}) {

No mail matches your filters

`; - container.innerHTML = filterHtml + itemsHtml; + container.innerHTML = filterHtml + summaryHtml + itemsHtml; // Add filter event handlers if (isAllMail) { @@ -225,6 +293,19 @@ function buildFilterUI(mail) {
+
+ + +
+
{ + currentFilters.category = categoryFilter.value; + renderMailList(container, mail, options); + }); + } + if (searchInput) { const handleSearch = debounce(() => { currentFilters.search = searchInput.value; @@ -281,12 +370,41 @@ function setupFilterHandlers(container, mail, options) { if (clearBtn) { clearBtn.addEventListener('click', () => { - currentFilters = { agentType: 'all', rig: 'all', search: '' }; + currentFilters = { agentType: 'all', rig: 'all', category: 'all', search: '' }; renderMailList(container, mail, options); }); } } +function renderMailSummary(mail) { + const summary = summarizeSignals(mail); + + return ` +
+
+ forum + ${summary.total} visible +
+
+ notification_important + ${summary.action} action needed +
+
+ warning + ${summary.crash} crashes +
+
+ build_circle + ${summary.recovery} recovery +
+
+ priority_high + ${summary.escalation} escalations +
+
+ `; +} + /** * Render a single mail item */ @@ -295,6 +413,7 @@ function renderMailItem(mail, index) { const priorityConfig = PRIORITY_CONFIG[priority] || PRIORITY_CONFIG.normal; const isUnread = !mail.read; const isFeedMail = mail.feedEvent; // From all-mail view + const signal = getMailSignal(mail); // Get agent types for color coding const fromType = getAgentType(mail.from); @@ -319,11 +438,11 @@ function renderMailItem(mail, index) { `; return ` -
- ${fromConfig.icon} + ${isFeedMail ? signal.icon : fromConfig.icon}
@@ -331,7 +450,10 @@ function renderMailItem(mail, index) { ${fromTo} ${formatTime(mail.timestamp)}
-
${escapeHtml(mail.subject || '(No Subject)')}
+
+
${escapeHtml(mail.subject || '(No Subject)')}
+ ${isFeedMail ? `${signal.icon}${signal.label}` : ''} +
${escapeHtml(truncate(mail.message || mail.body || '', 80))}
${mail.tags?.length ? ` diff --git a/js/components/pr-list.js b/js/components/pr-list.js index df3290b..64000e4 100644 --- a/js/components/pr-list.js +++ b/js/components/pr-list.js @@ -6,6 +6,8 @@ import { api } from '../api.js'; import { showToast } from './toast.js'; +import { state } from '../state.js'; +import { getGitHubBackedRigs } from '../shared/github-repos.js'; import { formatRelativeTime } from '../utils/formatting.js'; import { escapeHtml } from '../utils/html.js'; @@ -57,6 +59,12 @@ export async function loadPRs() { container.innerHTML = '
Loading PRs...
'; try { + const gitHubRigs = await getAvailableGitHubRigs(); + if (gitHubRigs.length === 0) { + renderNonGitHubState(container, 'pull requests'); + return; + } + prs = await api.getGitHubPRs(currentState); if (prs.length === 0) { @@ -64,6 +72,7 @@ export async function loadPRs() {
merge_type

No ${currentState} pull requests found

+ No pull request records were returned by the configured review integration.
`; return; @@ -85,6 +94,44 @@ export async function loadPRs() { } } +async function getAvailableGitHubRigs() { + const cachedStatus = state.get('status'); + if (cachedStatus?.rigs?.length) { + return getGitHubBackedRigs(cachedStatus); + } + + const status = await api.getStatus(); + return getGitHubBackedRigs(status); +} + +function renderNonGitHubState(container, resourceLabel) { + container.innerHTML = ` +
+
+ alt_route +
+

No review integration configured

+

This town currently exposes git remotes and worktrees only, so ${resourceLabel} are not available in this view.

+
+ + +
+
+ `; + + container.querySelectorAll('[data-navigate-view]').forEach(button => { + button.addEventListener('click', () => { + document.querySelector(`[data-view="${button.dataset.navigateView}"]`)?.click(); + }); + }); +} + /** * Create a PR card element */ @@ -130,7 +177,7 @@ function createPRCard(pr) {
@@ -179,7 +226,7 @@ function getReviewIcon(reviewDecision) { * Show PR detail modal */ async function showPRDetail(pr) { - // For now, just open in GitHub + // For now, just open the remote PR page // Could expand to show a detail modal with diffs, comments, etc. window.open(pr.url, '_blank'); } diff --git a/js/components/rig-list.js b/js/components/rig-list.js index d15a0d7..40c1226 100644 --- a/js/components/rig-list.js +++ b/js/components/rig-list.js @@ -26,7 +26,7 @@ export function renderRigList(container, rigs) { folder_special

No Rigs Yet

-

A rig connects your GitHub repository to Gas Town so AI agents can work on your code.

+

A rig connects your repository or local workspace to Gas Town so AI agents can work on your code.

` : ''}