From 47f83c1bfc13b3e70d3cb90e3599318f11dba333 Mon Sep 17 00:00:00 2001 From: "ilblackdragon@gmail.com" Date: Thu, 26 Mar 2026 19:15:54 -0700 Subject: [PATCH] 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) --- src/bridge/router.rs | 12 ++++- src/channels/web/static/app.js | 85 ++++++++++++++++++++++++++++--- src/channels/web/static/style.css | 33 ++++++++++++ 3 files changed, 120 insertions(+), 10 deletions(-) diff --git a/src/bridge/router.rs b/src/bridge/router.rs index 1ae2bfa0..d5e8dd25 100644 --- a/src/bridge/router.rs +++ b/src/bridge/router.rs @@ -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, - pub thread_ids: Vec, + pub threads: Vec, } // ── Engine query functions ─────────────────────────────────── @@ -1427,6 +1427,14 @@ pub async fn get_engine_mission(mission_id: &str) -> Result Result' + escapeHtml(m.status) + '' + ''; + // Goal — full-width markdown block + html += '

Goal

' + + '
' + renderMarkdown(m.goal) + '
'; + 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 || '∞')) @@ -4424,12 +4427,12 @@ function renderMissionDetail(m) { if (m.current_focus) { html += '

Current Focus

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

Success Criteria

' - + '
' + escapeHtml(m.success_criteria) + '
'; + + '
' + renderMarkdown(m.success_criteria) + '
'; } if (m.approach_history && m.approach_history.length > 0) { @@ -4440,12 +4443,26 @@ function renderMissionDetail(m) { html += ''; } - if (m.thread_ids && m.thread_ids.length > 0) { - html += '

Spawned Threads

    '; - m.thread_ids.forEach((tid) => { - html += '
  • ' + escapeHtml(tid) + '
  • '; + if (m.threads && m.threads.length > 0) { + html += '

    Spawned Threads

    ' + + '' + + '' + + ''; + m.threads.forEach((t) => { + var tState = t.state === 'Done' || t.state === 'Completed' ? 'completed' + : t.state === 'Failed' ? 'failed' + : t.state === 'Running' ? 'in_progress' + : 'pending'; + html += '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; }); - html += ''; + html += '
    GoalTypeStateStepsTokensCreated
    ' + escapeHtml(t.goal) + '' + escapeHtml(t.thread_type) + '' + escapeHtml(t.state) + '' + t.step_count + '' + t.total_tokens.toLocaleString() + '' + formatDate(t.created_at) + '
    '; } // 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 = '
    ' + + '' + + '

    Thread: ' + escapeHtml(t.goal) + '

    ' + + '' + escapeHtml(t.state) + '' + + '
    '; + + html += '
    ' + + 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) : '-') + + '
    '; + + if (t.messages && t.messages.length > 0) { + html += '

    Messages (' + t.messages.length + ')

    '; + t.messages.forEach(function(msg, i) { + var roleClass = msg.role === 'Assistant' ? 'assistant' : msg.role === 'User' ? 'user' : 'system'; + html += '
    ' + + '
    ' + escapeHtml(msg.role) + '
    ' + + '
    ' + renderMarkdown(msg.content) + '
    ' + + '
    '; + }); + html += '
    '; + } + + 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'); diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index 2b083ac9..a25657ca 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -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;