Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -598,8 +598,10 @@ SuperClaude includes a native desktop dashboard for visualizing and controlling
### Features

- **Feature Inventory** - Browse all 35 agents, 14 commands, 30+ skills, and 6 behavioral modes
- **Live Monitor** - Real-time execution tracking with event streaming and quality score visualization
- **Execution Control** - Start, stop, pause, and resume executions with configurable parameters
- **Live Monitor** - Real-time execution tracking with event streaming, heartbeat indicator, and quality score visualization
- **Execution Control** - Start, stop, pause, and resume executions with configurable parameters; expandable detail panel with 5 tabs (Run Instructions, Execution Log, Files Changed, Quality Breakdown, Execution Tree)
- **Execution Tree** - Visual tree of iterations, tool calls, and subagent spawns built from streaming events
- **Diff Viewer** - Inline diff display for Edit/Write tool invocations showing before/after changes
- **Historical Metrics** - View past session data, event timelines, and performance trends

### Quick Start
Expand Down Expand Up @@ -1073,7 +1075,7 @@ flowchart TB
Skills --> Structure

subgraph ScriptDetail["sc-implement/scripts/ (5 tools)"]
S1["select_agent.py (406 lines)<br/>Weighted agent selection"]
S1["select_agent.py (551 lines)<br/>Weighted agent selection"]
S2["run_tests.py (344 lines)<br/>Test framework detection"]
S3["evidence_gate.py (256 lines)<br/>Quality validation"]
S4["skill_learn.py (466 lines)<br/>Skill extraction"]
Expand Down Expand Up @@ -1789,6 +1791,10 @@ SuperClaude/
├── crates/ # Rust workspace
│ ├── dashboard/ # Tauri v2 desktop dashboard (Leptos WASM frontend)
│ │ └── frontend/src/
│ │ ├── components/ # UI: sidebar, execution_detail, execution_tree, diff_view, ...
│ │ ├── pages/ # inventory, monitor, control, history
│ │ └── state/ # Reactive signals (AppState, ExecutionTree, DTOs)
│ ├── proto/ # Protobuf/gRPC service definitions
│ ├── superclaude-core/ # Shared domain types and utilities
│ ├── superclaude-daemon/ # gRPC daemon (port 50051)
Expand Down Expand Up @@ -1817,10 +1823,8 @@ SuperClaude/
│ └── consensus/
├── Docs/ # Documentation
│ ├── Getting-Started/
│ ├── User-Guide/
│ ├── Developer-Guide/
│ └── Reference/
│ ├── quality-gates.md
│ └── README.md
└── archive/ # Archived Python SDK (v5)
```
Expand Down
173 changes: 172 additions & 1 deletion crates/dashboard/frontend/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ use crate::pages::control::ControlPage;
use crate::pages::history::HistoryPage;
use crate::pages::inventory::InventoryPage;
use crate::pages::monitor::MonitorPage;
use crate::state::{AgentEventDto, AppState, DaemonStatusDto, InventoryDto, Page};
use crate::state::{AgentEventDto, AppState, DaemonStatusDto, InventoryDto, Page,
TreeEdge, TreeNode, TreeNodeStatus, TreeNodeType};

#[component]
pub fn App() -> impl IntoView {
Expand Down Expand Up @@ -94,6 +95,176 @@ pub fn App() -> impl IntoView {
_ => {}
}

// Track event source for heartbeat/thinking indicator
if let Some(source) = event.data.get("source").and_then(|v| v.as_str()) {
state.last_event_source.set(source.to_string());
} else {
state.last_event_source.set(event.event_type.clone());
}

// Build the execution tree incrementally

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting the execution-tree event matching and mutation logic into a shared helper function that both App and build_tree_from_events call instead of duplicating it inline.

You’ve effectively duplicated the tree‑building rules inline in App, which does increase complexity and coupling. You can keep all functionality but centralize the logic in a reusable “tree updater” that both App and build_tree_from_events call.

1. Extract a reusable tree update function

In a shared module (e.g. state::execution_tree or pages::control::tree), define something like:

pub fn apply_event_to_tree(
    tree: &mut ExecutionTree,
    event: &AgentEventDto,
) {
    match event.event_type.as_str() {
        "iteration_started" => {
            let node_id = event.data.get("node_id")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string();
            let iteration = event.data.get("iteration")
                .and_then(|v| v.as_i64())
                .unwrap_or(0);

            if !node_id.is_empty() && !tree.nodes.iter().any(|n| n.node_id == node_id) {
                tree.nodes.push(TreeNode {
                    node_id,
                    parent_node_id: None,
                    node_type: TreeNodeType::Iteration,
                    label: format!("Iteration {}", iteration),
                    summary: String::new(),
                    status: TreeNodeStatus::Running,
                    x: 0.0,
                    y: 0.0,
                    event_data: Some(event.data.clone()),
                });
            }
        }
        "tool_invoked" => {
            let node_id = event.data.get("node_id")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string();
            let parent_node_id = event.data.get("parent_node_id")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string();
            let summary_val = event.data.get("summary")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string();

            if summary_val == "(result)" {
                // existing result logic
                let base_id = node_id.strip_suffix("-result").unwrap_or(&node_id).to_string();
                if let Some(node) = tree.nodes.iter_mut().find(|n| n.node_id == base_id) {
                    node.status = TreeNodeStatus::Success;
                    if let Some(output) = event.data.get("tool_output") {
                        if let Some(ref mut data) = node.event_data {
                            if let Some(obj) = data.as_object_mut() {
                                obj.insert("tool_output".to_string(), output.clone());
                            }
                        }
                    }
                }
            } else if !node_id.is_empty() && !tree.nodes.iter().any(|n| n.node_id == node_id) {
                let tool_name = event.data.get("tool_name")
                    .and_then(|v| v.as_str())
                    .unwrap_or("Tool")
                    .to_string();
                tree.nodes.push(TreeNode {
                    node_id: node_id.clone(),
                    parent_node_id: Some(parent_node_id.clone()),
                    node_type: TreeNodeType::ToolCall,
                    label: tool_name,
                    summary: summary_val,
                    status: TreeNodeStatus::Running,
                    x: 0.0,
                    y: 0.0,
                    event_data: Some(event.data.clone()),
                });
                if !parent_node_id.is_empty() {
                    tree.edges.push(TreeEdge {
                        from_id: parent_node_id,
                        to_id: node_id,
                    });
                }
            }
        }
        "iteration_completed" => {
            let node_id = event.data.get("node_id")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string();
            if !node_id.is_empty() {
                if let Some(node) = tree.nodes.iter_mut().find(|n| n.node_id == node_id) {
                    node.status = TreeNodeStatus::Success;
                }
            }
        }
        "subagent_spawned" => {
            // move your existing subagent_spawned logic here
        }
        "subagent_completed" => {
            // move your existing subagent_completed logic here
        }
        _ => {}
    }
}

You can iteratively migrate the remaining branches (subagent_spawned, subagent_completed) into this function, preserving the exact behavior you added.

2. Use it in App’s event handler

Replace the large match block in App with a narrow call that only gates on expanded_eid:

// Build the execution tree incrementally
if let Some(expanded_eid) = state.expanded_execution.get_untracked() {
    if expanded_eid == eid {
        state.execution_tree.update(|tree| {
            apply_event_to_tree(tree, &event);
        });
    }
}

That keeps the event loop in App focused on “when to update the tree” rather than “how to update the tree,” which makes the UI code easier to follow.

3. Reuse in build_tree_from_events

In control.rs, build_tree_from_events can then delegate to the same helper instead of duplicating logic:

pub fn build_tree_from_events(events: &[AgentEventDto]) -> ExecutionTree {
    let mut tree = ExecutionTree::default();
    for event in events {
        apply_event_to_tree(&mut tree, event);
    }
    tree
}

This removes the duplicated rules for node creation, edges, and status updates, and ensures future changes to tree semantics only need to be done in one place.

if let Some(expanded_eid) = state.expanded_execution.get_untracked() {
if expanded_eid == eid {
match event.event_type.as_str() {
"iteration_started" => {
let node_id = event.data.get("node_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let iteration = event.data.get("iteration")
.and_then(|v| v.as_i64())
.unwrap_or(0);
if !node_id.is_empty() {
state.execution_tree.update(|tree| {
if !tree.nodes.iter().any(|n| n.node_id == node_id) {
tree.nodes.push(TreeNode {
node_id,
parent_node_id: None,
node_type: TreeNodeType::Iteration,
label: format!("Iteration {}", iteration),
summary: String::new(),
status: TreeNodeStatus::Running,
x: 0.0,
y: 0.0,
event_data: Some(event.data.clone()),
});
}
});
}
}
"tool_invoked" => {
let node_id = event.data.get("node_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let parent_node_id = event.data.get("parent_node_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let summary_val = event.data.get("summary")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();

// Skip "(result)" pseudo-events — update existing node status instead
if summary_val == "(result)" {
let base_id = node_id.strip_suffix("-result").unwrap_or(&node_id).to_string();
state.execution_tree.update(|tree| {
if let Some(node) = tree.nodes.iter_mut().find(|n| n.node_id == base_id) {
node.status = TreeNodeStatus::Success;
// Store tool_output in event_data
if let Some(output) = event.data.get("tool_output") {
if let Some(ref mut data) = node.event_data {
if let Some(obj) = data.as_object_mut() {
obj.insert("tool_output".to_string(), output.clone());
}
}
}
}
});
} else if !node_id.is_empty() {
let tool_name = event.data.get("tool_name")
.and_then(|v| v.as_str())
.unwrap_or("Tool")
.to_string();
state.execution_tree.update(|tree| {
if !tree.nodes.iter().any(|n| n.node_id == node_id) {
tree.nodes.push(TreeNode {
node_id: node_id.clone(),
parent_node_id: Some(parent_node_id.clone()),
node_type: TreeNodeType::ToolCall,
label: tool_name,
summary: summary_val,
status: TreeNodeStatus::Running,
x: 0.0,
y: 0.0,
event_data: Some(event.data.clone()),
});
if !parent_node_id.is_empty() {
tree.edges.push(TreeEdge {
from_id: parent_node_id,
to_id: node_id,
});
}
}
});
}
}
"iteration_completed" => {
let node_id = event.data.get("node_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if !node_id.is_empty() {
state.execution_tree.update(|tree| {
if let Some(node) = tree.nodes.iter_mut().find(|n| n.node_id == node_id) {
node.status = TreeNodeStatus::Success;
}
});
}
}
"subagent_spawned" => {
let node_id = event.data.get("node_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let parent_node_id = event.data.get("parent_node_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let subagent_type = event.data.get("subagent_type")
.and_then(|v| v.as_str())
.unwrap_or("Subagent")
.to_string();
let task_summary = event.data.get("task_summary")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if !node_id.is_empty() {
state.execution_tree.update(|tree| {
if !tree.nodes.iter().any(|n| n.node_id == node_id) {
tree.nodes.push(TreeNode {
node_id: node_id.clone(),
parent_node_id: Some(parent_node_id.clone()),
node_type: TreeNodeType::SubagentSpawn,
label: subagent_type,
summary: task_summary,
status: TreeNodeStatus::Running,
x: 0.0,
y: 0.0,
event_data: Some(event.data.clone()),
});
if !parent_node_id.is_empty() {
tree.edges.push(TreeEdge {
from_id: parent_node_id,
to_id: node_id,
});
}
}
});
}
}
"subagent_completed" => {
let node_id = event.data.get("node_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let success = event.data.get("success")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !node_id.is_empty() {
state.execution_tree.update(|tree| {
if let Some(node) = tree.nodes.iter_mut().find(|n| n.node_id == node_id) {
node.status = if success { TreeNodeStatus::Success } else { TreeNodeStatus::Failed };
}
});
}
}
_ => {}
}
}
}

// Incrementally update the detail panel if this event is for the expanded execution
if let Some(expanded_eid) = state.expanded_execution.get_untracked() {
if expanded_eid == eid {
Expand Down
58 changes: 58 additions & 0 deletions crates/dashboard/frontend/src/components/diff_view.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//! Diff view component for showing file changes with old/new content.

use leptos::prelude::*;

#[component]
pub fn DiffView(
#[prop(into)] file_path: String,
#[prop(into)] action: String,
#[prop(into, default = String::new())] old_string: String,
#[prop(into, default = String::new())] new_string: String,
#[prop(into, default = String::new())] content: String,
) -> impl IntoView {
let path_display = file_path.clone();

match action.as_str() {
"edit" => {
view! {
<div class="diff-view">
<div class="diff-header">"Edit: " {path_display}</div>
{if !old_string.is_empty() {
let old_display = old_string.clone();
Some(view! {
<div class="diff-block diff-removed">
<div class="diff-block-header">"Removed"</div>
<pre class="diff-content">{old_display}</pre>
</div>
})
} else {
None
}}
{if !new_string.is_empty() {
let new_display = new_string.clone();
Some(view! {
<div class="diff-block diff-added">
<div class="diff-block-header">"Added"</div>
<pre class="diff-content">{new_display}</pre>
</div>
})
} else {
None
}}
</div>
}.into_any()
}
"write" => {
view! {
<div class="diff-view">
<div class="diff-header">"New File: " {path_display}</div>
<div class="diff-block diff-added">
<div class="diff-block-header">"New File"</div>
<pre class="diff-content">{content}</pre>
</div>
</div>
}.into_any()
}
_ => view! { <div></div> }.into_any(),
}
}
Loading
Loading