From 7c153d584b1acf39ddc58af85f7aaad139382738 Mon Sep 17 00:00:00 2001 From: "ilblackdragon@gmail.com" Date: Thu, 26 Mar 2026 18:53:39 -0700 Subject: [PATCH] feat(web): add Missions tab to gateway UI Add a full Missions page to the web gateway with list view, detail view, and action buttons (Fire, Pause, Resume). Backend: add /api/engine/missions/summary endpoint returning counts by status (active/paused/completed/failed). Frontend: - New "Missions" tab between Jobs and Routines - Summary cards showing mission counts by status - Table with name, goal, cadence type, thread count, status, actions - Detail view with goal, cadence, current focus, success criteria, approach history, spawned thread list, and action buttons - Fire/Pause/Resume actions with toast notifications - i18n support (English + Chinese) - CSS following the existing routines/jobs patterns Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/web/handlers/engine.rs | 23 +++ src/channels/web/server.rs | 10 +- src/channels/web/static/app.js | 197 ++++++++++++++++++++++++++ src/channels/web/static/i18n/en.js | 15 ++ src/channels/web/static/i18n/zh-CN.js | 15 ++ src/channels/web/static/index.html | 25 ++++ src/channels/web/static/style.css | 57 ++++++++ src/channels/web/types.rs | 9 ++ 8 files changed, 348 insertions(+), 3 deletions(-) diff --git a/src/channels/web/handlers/engine.rs b/src/channels/web/handlers/engine.rs index 99727f7e..2129375d 100644 --- a/src/channels/web/handlers/engine.rs +++ b/src/channels/web/handlers/engine.rs @@ -94,6 +94,29 @@ pub async fn engine_missions_handler( Ok(Json(EngineMissionListResponse { missions })) } +pub async fn engine_missions_summary_handler( + State(_state): State>, + AuthenticatedUser(_user): AuthenticatedUser, +) -> Result, (StatusCode, String)> { + let missions = crate::bridge::list_engine_missions(None) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let total = missions.len() as u64; + let active = missions.iter().filter(|m| m.status == "Active").count() as u64; + let paused = missions.iter().filter(|m| m.status == "Paused").count() as u64; + let completed = missions.iter().filter(|m| m.status == "Completed").count() as u64; + let failed = missions.iter().filter(|m| m.status == "Failed").count() as u64; + + Ok(Json(EngineMissionSummaryResponse { + total, + active, + paused, + completed, + failed, + })) +} + pub async fn engine_mission_detail_handler( State(_state): State>, AuthenticatedUser(_user): AuthenticatedUser, diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 4d3b6e42..9af14b9d 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -35,9 +35,9 @@ use crate::channels::web::auth::{ }; use crate::channels::web::handlers::engine::{ engine_mission_detail_handler, engine_mission_fire_handler, engine_mission_pause_handler, - engine_mission_resume_handler, engine_missions_handler, engine_project_detail_handler, - engine_projects_handler, engine_thread_detail_handler, engine_thread_events_handler, - engine_thread_steps_handler, engine_threads_handler, + engine_mission_resume_handler, engine_missions_handler, engine_missions_summary_handler, + engine_project_detail_handler, engine_projects_handler, engine_thread_detail_handler, + engine_thread_events_handler, engine_thread_steps_handler, engine_threads_handler, }; use crate::channels::web::handlers::jobs::{ job_files_list_handler, job_files_read_handler, jobs_cancel_handler, jobs_detail_handler, @@ -517,6 +517,10 @@ pub async fn start_server( get(engine_project_detail_handler), ) .route("/api/engine/missions", get(engine_missions_handler)) + .route( + "/api/engine/missions/summary", + get(engine_missions_summary_handler), + ) .route( "/api/engine/missions/{id}", get(engine_mission_detail_handler), diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 6b366482..8a727989 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -2212,6 +2212,7 @@ function switchTab(tab) { if (tab === 'memory') loadMemoryTree(); if (tab === 'jobs') loadJobs(); + if (tab === 'missions') loadMissions(); if (tab === 'routines') loadRoutines(); if (tab === 'logs') applyLogFilters(); if (tab === 'settings') { @@ -4317,6 +4318,184 @@ function deleteRoutine(id, name) { .catch((err) => showToast('Delete failed: ' + err.message, 'error')); } +// ── Missions ────────────────────────────────────────────── + +let currentMissionId = null; + +function loadMissions() { + currentMissionId = null; + const detail = document.getElementById('mission-detail'); + if (detail) detail.style.display = 'none'; + const table = document.getElementById('missions-table'); + if (table) table.style.display = ''; + + Promise.all([ + apiFetch('/api/engine/missions/summary'), + apiFetch('/api/engine/missions'), + ]).then(([summary, listData]) => { + renderMissionsSummary(summary); + renderMissionsList(listData.missions); + }).catch(() => {}); +} + +function renderMissionsSummary(s) { + document.getElementById('missions-summary').innerHTML = '' + + summaryCard(I18n.t('missions.summary.total'), s.total, '') + + summaryCard(I18n.t('missions.summary.active'), s.active, 'active') + + summaryCard(I18n.t('missions.summary.paused'), s.paused, '') + + summaryCard(I18n.t('missions.summary.completed'), s.completed, 'completed') + + summaryCard(I18n.t('missions.summary.failed'), s.failed, 'failed'); +} + +function renderMissionsList(missions) { + const tbody = document.getElementById('missions-tbody'); + const empty = document.getElementById('missions-empty'); + + if (!missions || missions.length === 0) { + tbody.innerHTML = ''; + empty.style.display = 'block'; + return; + } + + empty.style.display = 'none'; + tbody.innerHTML = missions.map((m) => { + const statusClass = m.status === 'Active' ? 'in_progress' + : m.status === 'Completed' ? 'completed' + : m.status === 'Paused' ? 'pending' + : 'failed'; + + return '' + + '' + escapeHtml(m.name) + '' + + '' + escapeHtml(m.goal) + '' + + '' + escapeHtml(m.cadence_type) + '' + + '' + m.thread_count + '' + + '' + escapeHtml(m.status) + '' + + '' + + (m.status === 'Active' ? ' ' : '') + + (m.status === 'Paused' ? ' ' : '') + + '' + + '' + + ''; + }).join(''); +} + +function openMissionDetail(id) { + currentMissionId = id; + apiFetch('/api/engine/missions/' + id).then((data) => { + renderMissionDetail(data.mission); + }).catch((err) => { + showToast('Failed to load mission: ' + err.message, 'error'); + }); +} + +function closeMissionDetail() { + currentMissionId = null; + loadMissions(); +} + +function renderMissionDetail(m) { + const table = document.getElementById('missions-table'); + if (table) table.style.display = 'none'; + document.getElementById('missions-empty').style.display = 'none'; + + const detail = document.getElementById('mission-detail'); + detail.style.display = 'block'; + + const statusClass = m.status === 'Active' ? 'in_progress' + : m.status === 'Completed' ? 'completed' + : m.status === 'Paused' ? 'pending' + : 'failed'; + + let html = '
' + + '' + + '

' + escapeHtml(m.name) + '

' + + '' + escapeHtml(m.status) + '' + + '
'; + + html += '
' + + metaItem('Goal', m.goal) + + metaItem('Cadence', m.cadence_type) + + metaItem('Status', m.status) + + metaItem('Threads Today', m.threads_today + ' / ' + (m.max_threads_per_day || '∞')) + + metaItem('Total Threads', m.thread_count) + + metaItem('Created', formatDate(m.created_at)) + + metaItem('Next Fire', m.next_fire_at ? formatDate(m.next_fire_at) : 'N/A') + + '
'; + + if (m.current_focus) { + html += '

Current Focus

' + + '
' + escapeHtml(m.current_focus) + '
'; + } + + if (m.success_criteria) { + html += '

Success Criteria

' + + '
' + escapeHtml(m.success_criteria) + '
'; + } + + if (m.approach_history && m.approach_history.length > 0) { + html += '

Approach History

    '; + m.approach_history.forEach((a) => { + html += '
  • ' + escapeHtml(a) + '
  • '; + }); + html += '
'; + } + + if (m.thread_ids && m.thread_ids.length > 0) { + html += '

Spawned Threads

    '; + m.thread_ids.forEach((tid) => { + html += '
  • ' + escapeHtml(tid) + '
  • '; + }); + html += '
'; + } + + // Action buttons + html += '
'; + if (m.status === 'Active') { + html += ' '; + } + if (m.status === 'Paused') { + html += ' '; + } + html += ''; + html += '
'; + + detail.innerHTML = html; +} + +function fireMission(id) { + apiFetch('/api/engine/missions/' + id + '/fire', { method: 'POST' }) + .then((data) => { + if (data.fired) { + showToast('Mission fired — thread ' + data.thread_id, 'success'); + } else { + showToast('Mission not fired (terminal or budget exhausted)', 'warning'); + } + if (currentMissionId === id) openMissionDetail(id); + else loadMissions(); + }) + .catch((err) => showToast('Fire failed: ' + err.message, 'error')); +} + +function pauseMission(id) { + apiFetch('/api/engine/missions/' + id + '/pause', { method: 'POST' }) + .then(() => { + showToast('Mission paused', 'success'); + if (currentMissionId === id) openMissionDetail(id); + else loadMissions(); + }) + .catch((err) => showToast('Pause failed: ' + err.message, 'error')); +} + +function resumeMission(id) { + apiFetch('/api/engine/missions/' + id + '/resume', { method: 'POST' }) + .then(() => { + showToast('Mission resumed', 'success'); + if (currentMissionId === id) openMissionDetail(id); + else loadMissions(); + }) + .catch((err) => showToast('Resume failed: ' + err.message, 'error')); +} + function formatRelativeTime(isoString) { if (!isoString) return '-'; const d = new Date(isoString); @@ -6018,6 +6197,24 @@ document.addEventListener('click', function(e) { case 'close-routine-detail': closeRoutineDetail(); break; + case 'open-mission': + openMissionDetail(el.dataset.id); + break; + case 'close-mission-detail': + closeMissionDetail(); + break; + case 'fire-mission': + e.stopPropagation(); + fireMission(el.dataset.id); + break; + case 'pause-mission': + e.stopPropagation(); + pauseMission(el.dataset.id); + break; + case 'resume-mission': + e.stopPropagation(); + resumeMission(el.dataset.id); + break; case 'view-run-job': e.preventDefault(); switchTab('jobs'); diff --git a/src/channels/web/static/i18n/en.js b/src/channels/web/static/i18n/en.js index 761767fe..e085f943 100644 --- a/src/channels/web/static/i18n/en.js +++ b/src/channels/web/static/i18n/en.js @@ -34,6 +34,7 @@ I18n.register('en', { 'tab.chat': 'Chat', 'tab.memory': 'Memory', 'tab.jobs': 'Jobs', + 'tab.missions': 'Missions', 'tab.routines': 'Routines', 'tab.settings': 'Settings', 'tab.extensions': 'Extensions', @@ -111,6 +112,20 @@ I18n.register('en', { 'jobs.viewJob': 'View Job', 'jobs.browse': 'Browse', + // Missions Tab + 'missions.name': 'Name', + 'missions.goal': 'Goal', + 'missions.cadence': 'Cadence', + 'missions.threads': 'Threads', + 'missions.status': 'Status', + 'missions.actions': 'Actions', + 'missions.noConfigured': 'No missions found. Ask the assistant to create one.', + 'missions.summary.total': 'Total', + 'missions.summary.active': 'Active', + 'missions.summary.paused': 'Paused', + 'missions.summary.completed': 'Completed', + 'missions.summary.failed': 'Failed', + // Routines Tab 'routines.summary': 'Routines Summary', 'routines.name': 'Name', diff --git a/src/channels/web/static/i18n/zh-CN.js b/src/channels/web/static/i18n/zh-CN.js index 0fb1568a..d6f135da 100644 --- a/src/channels/web/static/i18n/zh-CN.js +++ b/src/channels/web/static/i18n/zh-CN.js @@ -34,6 +34,7 @@ I18n.register('zh-CN', { 'tab.chat': '聊天', 'tab.memory': '记忆', 'tab.jobs': '任务', + 'tab.missions': '使命', 'tab.routines': '定时任务', 'tab.settings': '设置', 'tab.extensions': '扩展', @@ -111,6 +112,20 @@ I18n.register('zh-CN', { 'jobs.viewJob': '查看任务', 'jobs.browse': '浏览', + // 使命标签页 + 'missions.name': '名称', + 'missions.goal': '目标', + 'missions.cadence': '节奏', + 'missions.threads': '线程', + 'missions.status': '状态', + 'missions.actions': '操作', + 'missions.noConfigured': '暂无使命。请让助手创建一个。', + 'missions.summary.total': '总计', + 'missions.summary.active': '活跃', + 'missions.summary.paused': '暂停', + 'missions.summary.completed': '已完成', + 'missions.summary.failed': '失败', + // 定时任务标签页 'routines.summary': '定时任务摘要', 'routines.name': '名称', diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html index 7aa2c86f..00ed8e4c 100644 --- a/src/channels/web/static/index.html +++ b/src/channels/web/static/index.html @@ -96,6 +96,7 @@ +
@@ -257,6 +258,30 @@ + +
+
+
+ + + + + + + + + + + + +
NameGoalCadenceThreadsStatusActions
+ + +
+
+
diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index 87afea87..2b083ac9 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -2578,6 +2578,63 @@ body { } /* Routines Tab */ +/* ── Missions ──────────────────────────────────── */ + +.missions-container { + flex: 1; + overflow-y: auto; + padding: var(--space-4); +} + +.missions-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: var(--space-3); + margin-bottom: 20px; +} + +.missions-table { + width: 100%; + border-collapse: collapse; +} + +.missions-table th, +.missions-table td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid var(--border); + font-size: var(--text-sm); +} + +.missions-table th { + color: var(--text-secondary); + font-weight: 500; + text-transform: uppercase; + font-size: var(--text-xs); + letter-spacing: 0.5px; +} + +.missions-table tr:hover td { + background: var(--hover-surface); +} + +.mission-row { + cursor: pointer; +} + +.mission-detail { + padding: 16px 0; +} + +.truncate { + max-width: 300px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Routines ──────────────────────────────────── */ + .routines-container { flex: 1; overflow-y: auto; diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index a878552d..5f9d3ee1 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -857,6 +857,15 @@ pub struct EngineMissionListResponse { pub missions: Vec, } +#[derive(Debug, Serialize)] +pub struct EngineMissionSummaryResponse { + pub total: u64, + pub active: u64, + pub paused: u64, + pub completed: u64, + pub failed: u64, +} + #[derive(Debug, Serialize)] pub struct EngineMissionDetailResponse { pub mission: crate::bridge::EngineMissionDetail,