feat(web): improve mission detail with markdown goal and thread table

- Goal rendered as full-width markdown block instead of plain-text
  meta item (uses existing renderMarkdown/marked)
- Current focus and success criteria also rendered as markdown
- Spawned threads shown as a clickable table with goal, type, state,
  steps, tokens, and created date instead of a UUID list
- Clicking a thread row opens an inline thread detail view showing
  metadata grid and full message history with markdown rendering
- Back button returns to the mission detail view
- Backend: mission detail now returns full thread summaries (goal,
  state, step_count, tokens) instead of just thread IDs

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
2026-03-26 19:15:54 -07:00
co-authored by Claude Opus 4.6
parent 6d56d9cb11
commit 47f83c1bfc
3 changed files with 120 additions and 10 deletions
+10 -2
View File
@@ -1153,7 +1153,7 @@ pub struct EngineMissionDetail {
pub max_threads_per_day: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_fire_at: Option<String>,
pub thread_ids: Vec<String>,
pub threads: Vec<EngineThreadInfo>,
}
// ── Engine query functions ───────────────────────────────────
@@ -1427,6 +1427,14 @@ pub async fn get_engine_mission(mission_id: &str) -> Result<Option<EngineMission
let cadence_json = serde_json::to_value(&m.cadence).unwrap_or(serde_json::Value::Null);
// Load thread summaries for the spawned threads table
let mut threads = Vec::new();
for tid in &m.thread_history {
if let Ok(Some(thread)) = state.store.load_thread(*tid).await {
threads.push(thread_to_info(&thread));
}
}
Ok(Some(EngineMissionDetail {
info: EngineMissionInfo {
id: m.id.to_string(),
@@ -1445,7 +1453,7 @@ pub async fn get_engine_mission(mission_id: &str) -> Result<Option<EngineMission
threads_today: m.threads_today,
max_threads_per_day: m.max_threads_per_day,
next_fire_at: m.next_fire_at.map(|dt| dt.to_rfc3339()),
thread_ids: m.thread_history.iter().map(|t| t.to_string()).collect(),
threads,
}))
}
+77 -8
View File
@@ -4412,8 +4412,11 @@ function renderMissionDetail(m) {
+ '<span class="badge ' + statusClass + '">' + escapeHtml(m.status) + '</span>'
+ '</div>';
// Goal — full-width markdown block
html += '<div class="job-description"><h3>Goal</h3>'
+ '<div class="job-description-body">' + renderMarkdown(m.goal) + '</div></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 || '∞'))
@@ -4424,12 +4427,12 @@ function renderMissionDetail(m) {
if (m.current_focus) {
html += '<div class="job-description"><h3>Current Focus</h3>'
+ '<div class="job-description-body">' + escapeHtml(m.current_focus) + '</div></div>';
+ '<div class="job-description-body">' + renderMarkdown(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>';
+ '<div class="job-description-body">' + renderMarkdown(m.success_criteria) + '</div></div>';
}
if (m.approach_history && m.approach_history.length > 0) {
@@ -4440,12 +4443,26 @@ function renderMissionDetail(m) {
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>';
if (m.threads && m.threads.length > 0) {
html += '<div class="job-description"><h3>Spawned Threads</h3>'
+ '<table class="missions-table"><thead><tr>'
+ '<th>Goal</th><th>Type</th><th>State</th><th>Steps</th><th>Tokens</th><th>Created</th>'
+ '</tr></thead><tbody>';
m.threads.forEach((t) => {
var tState = t.state === 'Done' || t.state === 'Completed' ? 'completed'
: t.state === 'Failed' ? 'failed'
: t.state === 'Running' ? 'in_progress'
: 'pending';
html += '<tr class="mission-row" data-action="open-engine-thread" data-id="' + escapeHtml(t.id) + '">'
+ '<td class="truncate">' + escapeHtml(t.goal) + '</td>'
+ '<td>' + escapeHtml(t.thread_type) + '</td>'
+ '<td><span class="badge ' + tState + '">' + escapeHtml(t.state) + '</span></td>'
+ '<td>' + t.step_count + '</td>'
+ '<td>' + t.total_tokens.toLocaleString() + '</td>'
+ '<td>' + formatDate(t.created_at) + '</td>'
+ '</tr>';
});
html += '</ul></div>';
html += '</tbody></table></div>';
}
// Action buttons
@@ -4462,6 +4479,51 @@ function renderMissionDetail(m) {
detail.innerHTML = html;
}
function openEngineThread(threadId) {
apiFetch('/api/engine/threads/' + threadId).then((data) => {
var t = data.thread;
var detail = document.getElementById('mission-detail');
var stateClass = t.state === 'Done' || t.state === 'Completed' ? 'completed'
: t.state === 'Failed' ? 'failed'
: t.state === 'Running' ? 'in_progress'
: 'pending';
var html = '<div class="job-detail-header">'
+ '<button class="btn-back" data-action="back-to-mission">&larr; Back to Mission</button>'
+ '<h2>Thread: ' + escapeHtml(t.goal) + '</h2>'
+ '<span class="badge ' + stateClass + '">' + escapeHtml(t.state) + '</span>'
+ '</div>';
html += '<div class="job-meta-grid">'
+ metaItem('Thread ID', t.id)
+ metaItem('Type', t.thread_type)
+ metaItem('Steps', t.step_count)
+ metaItem('Tokens', t.total_tokens.toLocaleString())
+ metaItem('Cost', t.total_cost_usd > 0 ? '$' + t.total_cost_usd.toFixed(4) : '-')
+ metaItem('Max Iterations', t.max_iterations)
+ metaItem('Created', formatDate(t.created_at))
+ metaItem('Completed', t.completed_at ? formatDate(t.completed_at) : '-')
+ '</div>';
if (t.messages && t.messages.length > 0) {
html += '<div class="job-description"><h3>Messages (' + t.messages.length + ')</h3>';
t.messages.forEach(function(msg, i) {
var roleClass = msg.role === 'Assistant' ? 'assistant' : msg.role === 'User' ? 'user' : 'system';
html += '<div class="thread-message thread-msg-' + roleClass + '">'
+ '<div class="thread-msg-role">' + escapeHtml(msg.role) + '</div>'
+ '<div class="thread-msg-content">' + renderMarkdown(msg.content) + '</div>'
+ '</div>';
});
html += '</div>';
}
detail.innerHTML = html;
}).catch(function(err) {
showToast('Failed to load thread: ' + err.message, 'error');
});
}
function fireMission(id) {
apiFetch('/api/engine/missions/' + id + '/fire', { method: 'POST' })
.then((data) => {
@@ -6215,6 +6277,13 @@ document.addEventListener('click', function(e) {
e.stopPropagation();
resumeMission(el.dataset.id);
break;
case 'open-engine-thread':
openEngineThread(el.dataset.id);
break;
case 'back-to-mission':
if (currentMissionId) openMissionDetail(currentMissionId);
else closeMissionDetail();
break;
case 'view-run-job':
e.preventDefault();
switchTab('jobs');
+33
View File
@@ -2626,6 +2626,39 @@ body {
padding: 16px 0;
}
.thread-message {
border-left: 3px solid var(--border);
padding: 8px 12px;
margin-bottom: 8px;
border-radius: 0 4px 4px 0;
font-size: var(--text-sm);
}
.thread-msg-role {
font-weight: 600;
font-size: var(--text-xs);
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 4px;
}
.thread-msg-content {
overflow-x: auto;
}
.thread-msg-assistant {
border-left-color: var(--accent);
}
.thread-msg-user {
border-left-color: var(--success);
}
.thread-msg-system {
border-left-color: var(--text-secondary);
opacity: 0.7;
}
.truncate {
max-width: 300px;
white-space: nowrap;