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) <[email protected]>
This commit is contained in:
2026-03-26 18:53:39 -07:00
co-authored by Claude Opus 4.6
parent 701974ce58
commit 7c153d584b
8 changed files with 348 additions and 3 deletions
+23
View File
@@ -94,6 +94,29 @@ pub async fn engine_missions_handler(
Ok(Json(EngineMissionListResponse { missions }))
}
pub async fn engine_missions_summary_handler(
State(_state): State<Arc<GatewayState>>,
AuthenticatedUser(_user): AuthenticatedUser,
) -> Result<Json<EngineMissionSummaryResponse>, (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<Arc<GatewayState>>,
AuthenticatedUser(_user): AuthenticatedUser,
+7 -3
View File
@@ -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),
+197
View File
@@ -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 '<tr class="mission-row" data-action="open-mission" data-id="' + escapeHtml(m.id) + '">'
+ '<td>' + escapeHtml(m.name) + '</td>'
+ '<td class="truncate">' + escapeHtml(m.goal) + '</td>'
+ '<td>' + escapeHtml(m.cadence_type) + '</td>'
+ '<td>' + m.thread_count + '</td>'
+ '<td><span class="badge ' + statusClass + '">' + escapeHtml(m.status) + '</span></td>'
+ '<td>'
+ (m.status === 'Active' ? '<button class="btn-cancel" data-action="pause-mission" data-id="' + escapeHtml(m.id) + '">Pause</button> ' : '')
+ (m.status === 'Paused' ? '<button class="btn-restart" data-action="resume-mission" data-id="' + escapeHtml(m.id) + '">Resume</button> ' : '')
+ '<button class="btn-restart" data-action="fire-mission" data-id="' + escapeHtml(m.id) + '">Fire</button>'
+ '</td>'
+ '</tr>';
}).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 = '<div class="job-detail-header">'
+ '<button class="btn-back" data-action="close-mission-detail">&larr; Back</button>'
+ '<h2>' + escapeHtml(m.name) + '</h2>'
+ '<span class="badge ' + statusClass + '">' + escapeHtml(m.status) + '</span>'
+ '</div>';
html += '<div class="job-meta-grid">'
+ 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')
+ '</div>';
if (m.current_focus) {
html += '<div class="job-description"><h3>Current Focus</h3>'
+ '<div class="job-description-body">' + escapeHtml(m.current_focus) + '</div></div>';
}
if (m.success_criteria) {
html += '<div class="job-description"><h3>Success Criteria</h3>'
+ '<div class="job-description-body">' + escapeHtml(m.success_criteria) + '</div></div>';
}
if (m.approach_history && m.approach_history.length > 0) {
html += '<div class="job-description"><h3>Approach History</h3><ul>';
m.approach_history.forEach((a) => {
html += '<li>' + escapeHtml(a) + '</li>';
});
html += '</ul></div>';
}
if (m.thread_ids && m.thread_ids.length > 0) {
html += '<div class="job-description"><h3>Spawned Threads</h3><ul>';
m.thread_ids.forEach((tid) => {
html += '<li><code>' + escapeHtml(tid) + '</code></li>';
});
html += '</ul></div>';
}
// Action buttons
html += '<div style="margin-top:16px;">';
if (m.status === 'Active') {
html += '<button class="btn-cancel" data-action="pause-mission" data-id="' + escapeHtml(m.id) + '">Pause</button> ';
}
if (m.status === 'Paused') {
html += '<button class="btn-restart" data-action="resume-mission" data-id="' + escapeHtml(m.id) + '">Resume</button> ';
}
html += '<button class="btn-restart" data-action="fire-mission" data-id="' + escapeHtml(m.id) + '">Fire Now</button>';
html += '</div>';
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');
+15
View File
@@ -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',
+15
View File
@@ -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': '名称',
+25
View File
@@ -96,6 +96,7 @@
<button class="active" data-tab="chat" data-i18n="tab.chat">Chat</button>
<button data-tab="memory" data-i18n="tab.memory">Memory</button>
<button data-tab="jobs" data-i18n="tab.jobs">Jobs</button>
<button data-tab="missions" data-i18n="tab.missions">Missions</button>
<button data-tab="routines" data-i18n="tab.routines">Routines</button>
<button data-tab="settings" data-i18n="tab.settings">Settings</button>
<div class="spacer"></div>
@@ -257,6 +258,30 @@
</div>
<!-- Routines Tab -->
<!-- Missions Tab -->
<div class="tab-panel" id="tab-missions">
<div class="missions-container">
<div class="missions-summary" id="missions-summary"></div>
<table class="missions-table" id="missions-table">
<thead>
<tr>
<th data-i18n="missions.name">Name</th>
<th data-i18n="missions.goal">Goal</th>
<th data-i18n="missions.cadence">Cadence</th>
<th data-i18n="missions.threads">Threads</th>
<th data-i18n="missions.status">Status</th>
<th data-i18n="missions.actions">Actions</th>
</tr>
</thead>
<tbody id="missions-tbody"></tbody>
</table>
<div class="empty-state" id="missions-empty" style="display:none">
<span data-i18n="missions.noConfigured">No missions found. Ask the assistant to create one.</span>
</div>
<div class="mission-detail" id="mission-detail" style="display:none"></div>
</div>
</div>
<div class="tab-panel" id="tab-routines">
<div class="routines-container">
<div class="routines-summary" id="routines-summary"></div>
+57
View File
@@ -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;
+9
View File
@@ -857,6 +857,15 @@ pub struct EngineMissionListResponse {
pub missions: Vec<crate::bridge::EngineMissionInfo>,
}
#[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,