Real-time Agent Progress and User Control UI

Interacting with AI agents can often feel like giving a command to a black box. You trigger a task, wait, and eventually, an output appears. For a multi-agent system like Kanbots, this lack of transparency can lead to frustration and inefficiency. This chapter addresses that challenge by equipping our Kanbots application with real-time feedback and user controls.

By the end of this milestone, your Kanbots application will provide a dynamic interface that displays agent progress, streams logs, and allows users to pause, resume, or cancel agent tasks directly from the Kanban board. This dramatically improves the user experience, giving operators crucial insights and control over complex AI workflows.

Project Overview: Bringing Agents to Life

In previous chapters, we established the core Kanban board and the ability to associate AI agents with cards, executing tasks within isolated git worktrees. This chapter focuses on closing the feedback loop: making agent actions visible and controllable. We’re moving from a fire-and-forget model to an interactive, observable system, which is critical for debugging, managing costs, and iterating on AI-driven development.

Tech Stack for Real-time Interaction

The core technologies enabling this real-time interaction are:

  • Tauri (v2): Provides the robust Inter-Process Communication (IPC) layer between our Rust backend and Svelte frontend. Its event system is ideal for streaming updates, and its command system handles user actions.
  • Rust: The backend language, leveraging tokio for asynchronous task management and std::sync primitives for safe concurrent state management (e.g., Arc<Mutex>, AtomicBool, Notify).
  • Svelte (5): The frontend framework, designed for reactivity, which will efficiently update the UI as new agent logs and status changes arrive.

Milestone: Interactive Agent Management

This chapter aims to deliver the following functional improvements:

  1. Real-time Log Streaming: Agent output and progress messages from the Rust backend will stream directly to the Svelte frontend, displayed within the respective Kanban card.
  2. Dynamic Agent Status: The UI will reflect the current state of an agent (e.g., “idle”, “running”, “paused”, “cancelled”, “completed”).
  3. User Control Buttons: Implement “Run,” “Pause,” “Resume,” and “Cancel” buttons for each agent, allowing direct user intervention.
  4. Backend Agent State Management: The Rust backend will maintain an internal state for each running agent, enabling it to respond to control signals.

Architecture: Two-Way IPC for Control and Observation

Achieving real-time interaction requires a well-defined two-way communication channel between our Rust backend and Svelte frontend. Tauri’s IPC mechanisms are perfectly suited for this, allowing us to emit events from Rust to Svelte and invoke commands from Svelte to Rust.

Communication Flow

  1. Backend to Frontend (Observation): As an AI agent executes its task in the Rust backend, it will periodically emit custom events (e.g., agent_log) containing progress messages, status updates, and any generated output. The Svelte frontend listens for these events and updates the UI dynamically.
  2. Frontend to Backend (Control): The Svelte frontend exposes interactive buttons (e.g., “Pause”, “Resume”, “Cancel”). When a user clicks one, it invokes a corresponding Tauri command in the Rust backend. The backend then processes this command to alter the agent’s execution state.

Agent State Management in Rust

The Rust backend will maintain a central AgentStateManager to track each active agent. This manager will hold references to the agent’s background task (tokio::task::JoinHandle) and control primitives (Arc<tokio::sync::Notify> for pause/resume, Arc<std::sync::atomic::AtomicBool> for cancellation). When a command arrives from the frontend, the manager signals the appropriate agent task.

flowchart LR UI[Svelte Frontend] -->|Invoke Command| TauriIPC[Tauri IPC] TauriIPC --> RustBackend[Rust Backend] RustBackend -->|Emit Event| TauriIPC TauriIPC --> UI subgraph RustBackend_Detail["Rust Backend Components"] RustBackend --> StateManager[Agent State Manager] StateManager --> AgentTask[Agent Tokio Task] AgentTask -->|Uses| ControlFlags[Control Flags] AgentTask --> Worktree[Git Worktree] AgentTask -->|Sends Logs| RustBackend end style UI fill:#e0f7fa,stroke:#00bcd4,stroke-width:2px style RustBackend fill:#ffe0b2,stroke:#ff9800,stroke-width:2px style TauriIPC fill:#f0f4c3,stroke:#cddc39,stroke-width:2px style StateManager fill:#c8e6c9,stroke:#4caf50,stroke-width:2px style AgentTask fill:#ffccbc,stroke:#ff5722,stroke-width:2px style Worktree fill:#e1bee7,stroke:#9c27b0,stroke-width:2px style ControlFlags fill:#bbdefb,stroke:#2196f3,stroke-width:2px

Step-by-Step Implementation

We’ll begin by enhancing the Rust backend to emit events and manage agent states, then update the Svelte frontend to consume these events and provide the control UI.

1. Backend: Emitting Real-time Agent Logs and Progress

First, we’ll modify our run_agent_task command to send progress updates to the frontend using Tauri’s AppHandle.

File: src-tauri/src/main.rs

We need to define a struct for our event payload and then use app_handle.emit_all to send it.

// src-tauri/src/main.rs

use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tokio::task::JoinHandle;
use chrono::Utc; // For timestamps

// ... (existing imports like tauri, tauri_plugin_shell, serde)

// Define the AgentLog struct for event payload
// 🧠 Important: This struct must derive `Clone` and `serde::Serialize`
// for Tauri's event system to work correctly.
#[derive(Clone, serde::Serialize)]
struct AgentLog {
    card_id: String,
    agent_id: String,
    message: String,
    timestamp: String,
    log_type: String, // e.g., "info", "progress", "error", "status"
}

// ... (existing greet, create_worktree, switch_worktree commands)

// The `run_agent_task` command will be modified later to integrate state management.
// For now, let's just make it emit events.
// We are temporarily commenting out the `state` parameter to focus on event emission.
// It will be re-added in the next section.
#[tauri::command]
async fn run_agent_task_placeholder(
    app_handle: tauri::AppHandle, // Add this parameter to access Tauri's event system
    card_id: String,
    agent_id: String,
    task_description: String,
) -> Result<String, String> {
    // 📌 Key Idea: Use AppHandle to emit events from the backend to the frontend.
    // `emit_all` sends the event to all listening webview windows.

    // Emit a "starting" event
    app_handle.emit_all(
        "agent_log",
        &AgentLog {
            card_id: card_id.clone(),
            agent_id: agent_id.clone(),
            message: format!("[{}] Starting task: {}", agent_id, task_description),
            timestamp: Utc::now().to_rfc3339(),
            log_type: "info".to_string(),
        },
    ).map_err(|e| e.to_string())?;

    // Simulate agent work and progress
    for i in 1..=3 {
        let progress_message = format!("[{}] Working on step {}/3...", agent_id, i);
        app_handle.emit_all(
            "agent_log",
            &AgentLog {
                card_id: card_id.clone(),
                agent_id: agent_id.clone(),
                message: progress_message.clone(),
                timestamp: Utc::now().to_rfc3339(),
                log_type: "progress".to_string(),
            },
        ).map_err(|e| e.to_string())?;
        tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; // Simulate work
    }

    // Simulate modifying a worktree (from previous chapter)
    let worktree_path = format!("/tmp/kanbots_worktrees/{}/{}", card_id, agent_id);
    std::fs::create_dir_all(&worktree_path)
        .map_err(|e| format!("Failed to create worktree dir: {}", e))?;
    let file_content = format!("// Generated by {} for card {}\nconsole.log('Task completed!');", agent_id, card_id);
    let file_path = format!("{}/output.js", worktree_path);
    std::fs::write(&file_path, file_content)
        .map_err(|e| format!("Failed to write file to worktree: {}", e))?;

    app_handle.emit_all(
        "agent_log",
        &AgentLog {
            card_id: card_id.clone(),
            agent_id: agent_id.clone(),
            message: format!("[{}] Task completed successfully. File created: {}", agent_id, file_path),
            timestamp: Utc::now().to_rfc3339(),
            log_type: "info".to_string(),
        },
    ).map_err(|e| e.to_string())?;

    Ok(format!("Agent {} completed task for card {}", agent_id, card_id))
}

fn main() {
    tauri::Builder::default()
        .plugin(tauri_plugin_shell::init())
        .invoke_handler(tauri::generate_handler![
            greet,
            create_worktree,
            switch_worktree,
            run_agent_task_placeholder // Register the temporary command
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Cargo.toml additions: Ensure chrono is added with the serde feature:

# src-tauri/Cargo.toml
[dependencies]
chrono = { version = "0.4", features = ["serde"] }
# ... other dependencies
  • app_handle: tauri::AppHandle: This parameter grants access to the Tauri application instance, allowing us to emit events.
  • app_handle.emit_all("agent_log", &payload): This sends an event named "agent_log" to all listening webview windows. The AgentLog struct is serialized into JSON and sent as the event payload.
  • tokio::time::sleep: Used to simulate work, making the progress updates visible over time.
  • chrono::Utc::now().to_rfc3339(): Provides a standardized timestamp for each log entry.

2. Backend: Implementing Agent Control (Pause/Resume/Cancel)

Implementing robust pause/resume/cancel for asynchronous tasks in Rust requires careful use of concurrency primitives. We’ll introduce a global AgentStateManager to track and control agents.

File: src-tauri/src/main.rs

First, add the necessary tokio dependency if not already present, ensuring full features for task and sync.

Cargo.toml additions:

# src-tauri/Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] } # Use "full" for convenience in tutorials
# ... other dependencies

Now, define the AgentStateManager and integrate it into our Tauri application.

// src-tauri/src/main.rs

// ... (existing imports)
use std::sync::atomic::{AtomicBool, Ordering}; // For atomic cancellation flag
use tokio::sync::Notify; // For pause/resume signaling

// Global state to track agent tasks and their control flags
// 🧠 Important: Mutexes are crucial for safely sharing mutable state across threads.
// Arc allows multiple ownership of the same data.
struct AgentStateManager {
    // Stores handles to the running agent tasks, allowing us to abort them.
    tasks: Mutex<HashMap<String, JoinHandle<()>>>, // Key: card_id-agent_id
    // Notify is used for pause/resume. When `notify.notified().await` is called,
    // it blocks until `notify.notify_one()` or `notify.notify_waiters()` is called.
    pause_notifies: Mutex<HashMap<String, Arc<Notify>>>,
    // AtomicBool is a simple, thread-safe flag for cancellation.
    cancel_flags: Mutex<HashMap<String, Arc<AtomicBool>>>,
}

impl AgentStateManager {
    fn new() -> Self {
        AgentStateManager {
            tasks: Mutex::new(HashMap::new()),
            pause_notifies: Mutex::new(HashMap::new()),
            cancel_flags: Mutex::new(HashMap::new()),
        }
    }

    // Add a new agent task's control primitives to the manager
    fn add_task(
        &self,
        key: String,
        handle: JoinHandle<()>,
        pause_notify: Arc<Notify>,
        cancel_flag: Arc<AtomicBool>,
    ) {
        self.tasks.lock().unwrap().insert(key.clone(), handle);
        self.pause_notifies.lock().unwrap().insert(key.clone(), pause_notify);
        self.cancel_flags.lock().unwrap().insert(key, cancel_flag);
    }

    // Signal an agent task to cancel
    fn signal_cancel(&self, key: &str) -> bool {
        if let Some(flag) = self.cancel_flags.lock().unwrap().get(key) {
            flag.store(true, Ordering::SeqCst); // Set the cancellation flag
            // ⚡ Quick Note: Aborting the JoinHandle is a best-effort, non-graceful stop.
            // The AtomicBool provides a graceful exit path for the agent task itself.
            if let Some(handle) = self.tasks.lock().unwrap().remove(key) {
                handle.abort(); // Attempt to abort the Tokio task immediately
            }
            self.remove_task_entries(key);
            true
        } else {
            false
        }
    }

    // Signal an agent task to pause
    fn signal_pause(&self, key: &str) -> bool {
        if let Some(notify) = self.pause_notifies.lock().unwrap().get(key) {
            // ⚠️ What can go wrong: `Notify` does not inherently "pause" a task.
            // It unblocks tasks that are `await`ing on it. To pause, the agent task
            // itself must check a state and then `await notify.notified()` when paused.
            // For now, we'll just log this as a signal. The agent task logic needs to implement the actual waiting.
            println!("Signaling PAUSE for agent: {}", key);
            // We don't call notify_one() here, as that would *unpause* a waiting task.
            // The agent task will detect the pause state and then wait on the notify.
            true
        } else {
            false
        }
    }

    // Signal an agent task to resume
    fn signal_resume(&self, key: &str) -> bool {
        if let Some(notify) = self.pause_notifies.lock().unwrap().get(key) {
            notify.notify_one(); // Unblocks one task waiting on this notify
            println!("Signaling RESUME for agent: {}", key);
            true
        } else {
            false
        }
    }

    // Helper to remove all entries for a task after completion or cancellation
    fn remove_task_entries(&self, key: &str) {
        self.tasks.lock().unwrap().remove(key);
        self.pause_notifies.lock().unwrap().remove(key);
        self.cancel_flags.lock().unwrap().remove(key);
    }
}

// Make AgentStateManager available globally via Tauri's managed state
// ⚡ Real-world insight: Tauri's `manage` allows you to inject shared, immutable
// references to state into your command functions.
#[derive(Default)]
struct AppState {
    agent_manager: AgentStateManager,
}

// Update main function to register our managed state
fn main() {
    tauri::Builder::default()
        .plugin(tauri_plugin_shell::init())
        .manage(AppState::default()) // Register our state manager here
        .invoke_handler(tauri::generate_handler![
            greet,
            create_worktree,
            switch_worktree,
            run_agent_task, // This will be our new, updated command
            pause_agent,    // New command for pausing
            resume_agent,   // New command for resuming
            cancel_agent    // New command for canceling
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

// New Tauri commands for control
#[tauri::command]
async fn pause_agent(
    card_id: String,
    agent_id: String,
    state: tauri::State<'_, AppState>, // Access managed state
) -> Result<(), String> {
    let key = format!("{}-{}", card_id, agent_id);
    if state.agent_manager.signal_pause(&key) {
        Ok(())
    } else {
        Err(format!("Agent {} not found or not running.", key))
    }
}

#[tauri::command]
async fn resume_agent(
    card_id: String,
    agent_id: String,
    state: tauri::State<'_, AppState>,
) -> Result<(), String> {
    let key = format!("{}-{}", card_id, agent_id);
    if state.agent_manager.signal_resume(&key) {
        Ok(())
    } else {
        Err(format!("Agent {} not found or not running.", key))
    }
}

#[tauri::command]
async fn cancel_agent(
    card_id: String,
    agent_id: String,
    state: tauri::State<'_, AppState>,
) -> Result<(), String> {
    let key = format!("{}-{}", card_id, agent_id);
    if state.agent_manager.signal_cancel(&key) {
        Ok(())
    } else {
        Err(format!("Agent {} not found or not running.", key))
    }
}

// Modify run_agent_task to register its task and check for cancellation/pause
// This replaces the `run_agent_task_placeholder` from the previous step.
#[tauri::command]
async fn run_agent_task(
    app_handle: tauri::AppHandle,
    state: tauri::State<'_, AppState>, // Access managed state
    card_id: String,
    agent_id: String,
    task_description: String,
) -> Result<String, String> {
    let task_key = format!("{}-{}", card_id, agent_id);
    let cancel_flag = Arc::new(AtomicBool::new(false));
    let pause_notify = Arc::new(Notify::new());

    // Clone Arc for the spawned task and for the manager
    let cancel_flag_for_task = cancel_flag.clone();
    let pause_notify_for_task = pause_notify.clone();
    let app_handle_for_task = app_handle.clone();
    let card_id_for_task = card_id.clone();
    let agent_id_for_task = agent_id.clone();
    let state_for_task = state.clone(); // Clone the state for task cleanup

    // Spawn the agent logic into a separate Tokio task
    let handle = tokio::spawn(async move {
        // Initial status update
        app_handle_for_task.emit_all(
            "agent_log",
            &AgentLog {
                card_id: card_id_for_task.clone(),
                agent_id: agent_id_for_task.clone(),
                message: format!("[{}] Starting task: {}", agent_id_for_task, task_description),
                timestamp: Utc::now().to_rfc3339(),
                log_type: "status_running".to_string(), // Specific status type
            },
        ).unwrap_or_else(|e| eprintln!("Error emitting log: {}", e));


        for i in 1..=5 { // More steps to better demonstrate pause/cancel
            // Check for cancellation signal
            if cancel_flag_for_task.load(Ordering::SeqCst) {
                app_handle_for_task.emit_all(
                    "agent_log",
                    &AgentLog {
                        card_id: card_id_for_task.clone(),
                        agent_id: agent_id_for_task.clone(),
                        message: format!("[{}] Task cancelled by user.", agent_id_for_task),
                        timestamp: Utc::now().to_rfc3339(),
                        log_type: "status_cancelled".to_string(),
                    },
                ).unwrap_or_else(|e| eprintln!("Error emitting log: {}", e));
                state_for_task.agent_manager.remove_task_entries(&task_key); // Clean up
                return; // Exit the task gracefully
            }

            // ⚠️ What can go wrong: Implementing true pause.
            // To truly pause, the agent task needs to `await pause_notify_for_task.notified().await;`
            // when a pause signal is received. For this demonstration, we'll simulate a pause point
            // and emit a log, but not actually block the task.
            // A full implementation would involve a shared `AtomicBool` for `is_paused` and the `Notify`
            // to release the task from its waiting state.
            if i == 3 {
                 app_handle_for_task.emit_all(
                    "agent_log",
                    &AgentLog {
                        card_id: card_id_for_task.clone(),
                        agent_id: agent_id_for_task.clone(),
                        message: format!("[{}] Simulating potential pause point.", agent_id_for_task),
                        timestamp: Utc::now().to_rfc3339(),
                        log_type: "info".to_string(),
                    },
                ).unwrap_or_else(|e| eprintln!("Error emitting log: {}", e));
                // In a production system, a pause would look like:
                // if is_paused_flag.load(Ordering::SeqCst) {
                //     app_handle_for_task.emit_all("agent_log", ... "status_paused")...;
                //     pause_notify_for_task.notified().await; // Blocks until resumed
                //     app_handle_for_task.emit_all("agent_log", ... "status_running")...;
                // }
            }

            let progress_message = format!("[{}] Working on step {}/5...", agent_id_for_task, i);
            app_handle_for_task.emit_all(
                "agent_log",
                &AgentLog {
                    card_id: card_id_for_task.clone(),
                    agent_id: agent_id_for_task.clone(),
                    message: progress_message.clone(),
                    timestamp: Utc::now().to_rfc3339(),
                    log_type: "progress".to_string(),
                },
            ).unwrap_or_else(|e| eprintln!("Error emitting log: {}", e));
            tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; // Simulate work
        }

        // Simulate modifying a worktree
        let worktree_path = format!("/tmp/kanbots_worktrees/{}/{}", card_id_for_task, agent_id_for_task);
        std::fs::create_dir_all(&worktree_path)
            .unwrap_or_else(|e| eprintln!("Failed to create worktree dir: {}", e));
        let file_content = format!("// Generated by {} for card {}\nconsole.log('Task completed!');", agent_id_for_task, card_id_for_task);
        let file_path = format!("{}/output.js", worktree_path);
        std::fs::write(&file_path, file_content)
            .unwrap_or_else(|e| eprintln!("Failed to write file to worktree: {}", e));

        app_handle_for_task.emit_all(
            "agent_log",
            &AgentLog {
                card_id: card_id_for_task.clone(),
                agent_id: agent_id_for_task.clone(),
                message: format!("[{}] Task completed successfully. File created: {}", agent_id_for_task, file_path),
                timestamp: Utc::now().to_rfc3339(),
                log_type: "status_completed".to_string(),
            },
        ).unwrap_or_else(|e| eprintln!("Error emitting log: {}", e));

        state_for_task.agent_manager.remove_task_entries(&task_key); // Clean up on completion
    });

    state.agent_manager.add_task(task_key, handle, pause_notify, cancel_flag);

    Ok(format!("Agent {} task started for card {}", agent_id, card_id))
}
  • AgentStateManager: This struct centralizes the management of running agent tasks. It uses Arc<Mutex<HashMap<...>>> to safely store JoinHandles, Notify objects, and AtomicBool flags.
    • Arc<T>: Enables multiple owners of the same data, crucial for sharing state between the AgentStateManager and the spawned tokio::task.
    • Mutex<T>: Provides exclusive access to the HashMaps, preventing data races when multiple threads try to modify them.
    • JoinHandle<()>: A handle to a spawned Tokio task. Calling .abort() on it attempts to cancel the task.
    • AtomicBool: A thread-safe boolean. We use it as a flag for graceful cancellation. The agent task periodically checks this flag.
    • tokio::sync::Notify: A synchronization primitive for signaling. notify.notified().await will block until notify.notify_one() is called. This is the foundation for pause/resume.
  • tauri::State<'_, AppState>: This allows our Tauri commands to access the shared AppState instance, which holds our AgentStateManager.
  • tokio::spawn: Each run_agent_task now spawns the actual agent logic into a separate Tokio task. This ensures the Tauri command returns immediately, keeping the UI responsive, while the agent runs in the background.
  • Cancellation Logic: The agent task periodically checks cancel_flag_for_task.load(Ordering::SeqCst). If true, it performs cleanup and exits. The signal_cancel function also calls handle.abort() as a forceful backup.
  • Pause/Resume Caveat: The current signal_pause only logs the intent. For a true pause, the agent task itself would need to enter a waiting state (await pause_notify_for_task.notified().await;) when a pause_agent command is issued. The signal_resume command would then call notify.notify_one() to unblock it. This requires a more complex state machine within the agent task, which is beyond the scope of this UI-focused chapter but important for production.

3. Frontend: Displaying Real-time Logs and Control Buttons

Now, let’s update our Svelte frontend component to listen for these events and provide the UI elements.

File: src/lib/components/KanbanCard.svelte

<!-- src/lib/components/KanbanCard.svelte -->
<script lang="ts">
  import { createEventDispatcher, onMount, onDestroy } from 'svelte';
  import { invoke } from '@tauri-apps/api/core';
  import { listen } from '@tauri-apps/api/event';
  import type { Agent, KanbanCard as CardType } from '../types'; // Alias KanbanCard to avoid conflict
  import { cardStore } from '../stores'; // Assuming you have a store for cards

  export let card: CardType; // Use the aliased type
  export let columnId: string;

  const dispatch = createEventDispatcher();

  // Stores logs per agent
  let agentLogs: { [agentId: string]: { message: string; timestamp: string; log_type: string }[] } = {};
  // Stores current status per agent
  let agentStatuses: { [agentId: string]: 'idle' | 'running' | 'paused' | 'cancelled' | 'completed' | 'error' } = {};

  // Function to scroll logs to the bottom
  function scrollToBottom(agentId: string) {
    const logContainer = document.querySelector(`.log-output-${agentId}`);
    if (logContainer) {
      logContainer.scrollTop = logContainer.scrollHeight;
    }
  }

  onMount(async () => {
    // Listen for agent log events
    const unlisten = await listen<{ card_id: string; agent_id: string; message: string; timestamp: string; log_type: string }>(
      'agent_log',
      (event) => {
        const { card_id, agent_id, message, timestamp, log_type } = event.payload;
        if (card_id === card.id) {
          if (!agentLogs[agent_id]) {
            agentLogs[agent_id] = [];
          }
          agentLogs[agent_id] = [...agentLogs[agent_id], { message, timestamp, log_type }];
          
          // Update status based on specific log types
          if (log_type === 'status_running') {
            agentStatuses[agent_id] = 'running';
          } else if (log_type === 'status_cancelled') {
            agentStatuses[agent_id] = 'cancelled';
          } else if (log_type === 'status_completed') {
            agentStatuses[agent_id] = 'completed';
          } else if (log_type === 'error') {
             // If an error log comes in, and not already cancelled/completed, set to error
            if (!['cancelled', 'completed'].includes(agentStatuses[agent_id])) {
                agentStatuses[agent_id] = 'error';
            }
          }
          // The pause/resume status will be updated on command invocation, not log type
          
          agentLogs = agentLogs; // Trigger Svelte reactivity
          agentStatuses = agentStatuses; // Trigger Svelte reactivity
          
          // Scroll to bottom after updates
          setTimeout(() => scrollToBottom(agent_id), 0);
        }
      }
    );

    onDestroy(() => {
      unlisten();
    });
  });

  async function runAgent(agentId: string, taskDescription: string) {
    try {
      agentStatuses[agentId] = 'running';
      agentLogs[agentId] = []; // Clear previous logs for a new run
      agentLogs = agentLogs;
      agentStatuses = agentStatuses;
      await invoke('run_agent_task', { cardId: card.id, agentId, taskDescription });
    } catch (error) {
      console.error('Error running agent:', error);
      agentLogs[agentId] = [...(agentLogs[agentId] || []), { message: `Error: ${error}`, timestamp: new Date().toISOString(), log_type: 'error' }];
      agentStatuses[agentId] = 'error';
      agentLogs = agentLogs;
      agentStatuses = agentStatuses;
    }
  }

  async function pauseAgent(agentId: string) {
    try {
      await invoke('pause_agent', { cardId: card.id, agentId });
      agentStatuses[agentId] = 'paused'; // Optimistic UI update
      agentLogs[agentId] = [...(agentLogs[agentId] || []), { message: `[${agentId}] Pause signal sent.`, timestamp: new Date().toISOString(), log_type: 'info' }];
      agentStatuses = agentStatuses;
      agentLogs = agentLogs;
    } catch (error) {
      console.error('Error pausing agent:', error);
      agentLogs[agentId] = [...(agentLogs[agentId] || []), { message: `Error pausing: ${error}`, timestamp: new Date().toISOString(), log_type: 'error' }];
      agentLogs = agentLogs;
    }
  }

  async function resumeAgent(agentId: string) {
    try {
      await invoke('resume_agent', { cardId: card.id, agentId });
      agentStatuses[agentId] = 'running'; // Optimistic UI update
      agentLogs[agentId] = [...(agentLogs[agentId] || []), { message: `[${agentId}] Resume signal sent.`, timestamp: new Date().toISOString(), log_type: 'info' }];
      agentStatuses = agentStatuses;
      agentLogs = agentLogs;
    } catch (error) {
      console.error('Error resuming agent:', error);
      agentLogs[agentId] = [...(agentLogs[agentId] || []), { message: `Error resuming: ${error}`, timestamp: new Date().toISOString(), log_type: 'error' }];
      agentLogs = agentLogs;
    }
  }

  async function cancelAgent(agentId: string) {
    try {
      await invoke('cancel_agent', { cardId: card.id, agentId });
      agentStatuses[agentId] = 'cancelled'; // Optimistic UI update
      agentLogs[agentId] = [...(agentLogs[agentId] || []), { message: `[${agentId}] Cancel signal sent.`, timestamp: new Date().toISOString(), log_type: 'error' }];
      agentStatuses = agentStatuses;
      agentLogs = agentLogs;
    } catch (error) {
      console.error('Error canceling agent:', error);
      agentLogs[agentId] = [...(agentLogs[agentId] || []), { message: `Error canceling: ${error}`, timestamp: new Date().toISOString(), log_type: 'error' }];
      agentLogs = agentLogs;
    }
  }

  // ... (existing drag/drop handlers, if any)
</script>

<div class="kanban-card" draggable="true" on:dragstart on:dragend>
  <h3>{card.title}</h3>
  <p>{card.description}</p>

  {#each card.agents as agent (agent.id)}
    <div class="agent-section">
      <h4>Agent: {agent.name} ({agent.persona})</h4>
      <p>Status: <span class="agent-status status-{agentStatuses[agent.id] || 'idle'}">{agentStatuses[agent.id] || 'idle'}</span></p>
      <div class="agent-controls">
        {#if agentStatuses[agent.id] === 'running'}
          <button on:click={() => pauseAgent(agent.id)}>Pause</button>
          <button on:click={() => cancelAgent(agent.id)}>Cancel</button>
        {:else if agentStatuses[agent.id] === 'paused'}
          <button on:click={() => resumeAgent(agent.id)}>Resume</button>
          <button on:click={() => cancelAgent(agent.id)}>Cancel</button>
        {:else if ['idle', 'completed', 'cancelled', 'error'].includes(agentStatuses[agent.id]) || !agentStatuses[agent.id]}
          <button on:click={() => runAgent(agent.id, `Develop feature for card ${card.id}`)}>Run Agent</button>
        {/if}
      </div>
      <div class="agent-logs">
        <h5>Logs:</h5>
        <div class="log-output log-output-{agent.id}">
          {#each agentLogs[agent.id] || [] as log}
            <p class="log-entry log-{log.log_type}">
              [{new Date(log.timestamp).toLocaleTimeString()}] {log.message}
            </p>
          {/each}
        </div>
      </div>
    </div>
  {/each}

  <!-- ... (existing card content) -->
</div>

<style>
  /* Add basic styling for the new elements */
  .kanban-card {
    background-color: white;
    padding: 15px;
    border-radius: 8px;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
    margin-bottom: 10px;
    cursor: grab;
  }
  .agent-section {
    margin-top: 15px;
    padding-top: 10px;
    border-top: 1px solid #eee;
  }
  .agent-controls button {
    margin-right: 5px;
    padding: 5px 10px;
    border-radius: 4px;
    border: 1px solid #ccc;
    background-color: #f0f0f0;
    cursor: pointer;
  }
  .agent-controls button:hover {
    background-color: #e0e0e0;
  }
  .agent-logs {
    margin-top: 10px;
    max-height: 150px; /* Limit height for scrollability */
    overflow-y: auto;
    background-color: #f9f9f9;
    border: 1px solid #ddd;
    padding: 8px;
    border-radius: 4px;
    font-family: monospace;
    font-size: 0.85em;
  }
  .log-output p {
    margin: 0;
    line-height: 1.4;
  }
  /* Specific log type styling */
  .log-error {
    color: red;
    font-weight: bold;
  }
  .log-progress {
    color: #007bff; /* Blue for progress */
  }
  .log-status_running {
    color: green;
  }
  .log-status_completed {
    color: darkgreen;
  }
  .log-status_cancelled {
    color: orange;
    font-weight: bold;
  }

  /* Agent status badge styling */
  .agent-status {
      padding: 2px 6px;
      border-radius: 4px;
      font-weight: bold;
      font-size: 0.8em;
      text-transform: uppercase;
  }
  .status-idle { background-color: #e0e0e0; color: #555; }
  .status-running { background-color: #d4edda; color: #155724; }
  .status-paused { background-color: #fff3cd; color: #856404; }
  .status-cancelled { background-color: #f8d7da; color: #721c24; }
  .status-completed { background-color: #cce5ff; color: #004085; }
  .status-error { background-color: #f8d7da; color: #721c24; }
</style>
  • onMount and listen: The KanbanCard component sets up an event listener for agent_log events from the Tauri backend when it mounts.
  • agentLogs and agentStatuses: These Svelte reactive variables store the streaming logs and the current status for each agent on the card, keyed by agent.id.
  • invoke for Control: Functions like runAgent, pauseAgent, resumeAgent, and cancelAgent use invoke to call the corresponding Rust Tauri commands. The UI updates optimistically, assuming the command will succeed.
  • Conditional Rendering: The control buttons (Pause, Resume, Cancel, Run Agent) are conditionally rendered based on the agent’s current status, providing an intuitive user experience.
  • Log Display and Scrolling: A div with overflow-y: auto is used to display agent logs. The scrollToBottom function ensures new logs are always visible.
  • Styling: Basic CSS is added to differentiate log types (e.g., errors in red) and status badges for better visual feedback.
  • agentLogs = agentLogs; and agentStatuses = agentStatuses;: These are essential Svelte patterns to trigger reactivity when modifying an object or array directly.

4. Frontend: Type Definitions

Ensure your Svelte project has the necessary type definitions.

File: src/lib/types.ts (create if it doesn’t exist, or update)

// src/lib/types.ts

export interface Agent {
  id: string;
  name: string;
  persona: string;
  // Add other agent properties as needed
}

export interface KanbanCard {
  id: string;
  title: string;
  description: string;
  columnId: string;
  agents: Agent[]; // Ensure agents array is part of the card
  // Add other card properties as needed
}

export interface KanbanColumn {
  id: string;
  title: string;
  cardIds: string[];
}

5. Frontend: Update Card Store with Agents

Make sure your cardStore includes an agents array for each card, with some dummy data for testing.

File: src/lib/stores.ts

// src/lib/stores.ts

import { writable } from 'svelte/store';
import type { KanbanCard, KanbanColumn } from './types';

// Initial dummy data for demonstration
const initialCards: KanbanCard[] = [
  { id: 'card-1', title: 'Implement User Auth', description: 'Setup JWT authentication for API endpoints.', columnId: 'todo',
    agents: [
      { id: 'agent-1-dev', name: 'Claude Code Dev', persona: 'Developer' },
      { id: 'agent-1-rev', name: 'Codex Reviewer', persona: 'Code Reviewer' },
    ]
  },
  { id: 'card-2', title: 'Design Database Schema', description: 'Outline tables and relationships for user data.', columnId: 'in-progress',
    agents: [
      { id: 'agent-2-arch', name: 'Claude Code Architect', persona: 'Architect' },
    ]
  },
  { id: 'card-3', title: 'Refactor Legacy Module', description: 'Improve readability and performance of old code.', columnId: 'todo',
    agents: [
      { id: 'agent-3-opt', name: 'Codex Optimizer', persona: 'Refactorer' },
    ]
  },
];

const initialColumns: KanbanColumn[] = [
  { id: 'todo', title: 'To Do', cardIds: ['card-1', 'card-3'] },
  { id: 'in-progress', title: 'In Progress', cardIds: ['card-2'] },
  { id: 'done', title: 'Done', cardIds: [] },
];

export const cardStore = writable<KanbanCard[]>(initialCards);
export const columnStore = writable<KanbanColumn[]>(initialColumns);

Testing & Verification

It’s time to see our real-time feedback and control in action.

  1. Start the Tauri App:
    npm run tauri dev
  2. Inspect the UI:
    • The Kanbots application should launch. Navigate to a card that has agents assigned (e.g., “Implement User Auth”).
    • You should see a new “Agent” section for each agent, displaying its name, persona, and an initial “Status: idle”. A “Run Agent” button should be visible, along with an empty “Logs” area.
  3. Run an Agent:
    • Click the “Run Agent” button for one of the agents (e.g., “Claude Code Dev”).
    • Expected behavior:
      • The agent’s “Status” should immediately change to “running”.
      • The “Run Agent” button should be replaced by “Pause” and “Cancel” buttons.
      • The “Logs” area should start displaying real-time messages, indicating the agent’s progress (e.g., “Starting task…”, “Working on step X/5…”, “Simulating potential pause point…”, “Task completed successfully…”). Logs should scroll automatically.
      • Finally, the status should change to “completed”, and the control buttons should revert to “Run Agent”.
  4. Test Pause/Resume (Conceptual):
    • Start an agent. While it’s running, click “Pause”.
    • Expected behavior: The status should change to “paused”, and a log entry like [agent-X] Pause signal sent. should appear.
    • Click “Resume”.
    • Expected behavior: The status should revert to “running”, and a log entry like [agent-X] Resume signal sent. should appear.
    • Note: As discussed, the agent task in Rust doesn’t actually block on pause in this simplified example. The UI changes reflect the signal being sent. A full implementation would require the agent’s internal logic to actively wait.
  5. Test Cancel:
    • Start another agent. While it’s running, click “Cancel”.
    • Expected behavior:
      • The status should change to “cancelled”, and a log entry like [agent-X] Cancel signal sent. should appear.
      • The logs should also show [agent-X] Task cancelled by user..
      • The control buttons should revert to “Run Agent”.
  6. Verify Worktree Output:
    • After an agent completes successfully, check your /tmp/kanbots_worktrees/ directory (or wherever your worktrees are configured). You should find the worktree directory for the card and agent (e.g., /tmp/kanbots_worktrees/card-1/agent-1-dev/), containing the output.js file generated by the agent.

If the logs appear in real-time, statuses update, and buttons respond as described, you’ve successfully implemented real-time feedback and control.

Production Considerations

Implementing real-time interaction and control for background processes involves several critical concerns for a production-grade application:

  • IPC Performance and Throttling:
    • Why it matters: Emitting events too frequently (e.g., hundreds per second) can overwhelm the IPC channel, leading to UI lag or dropped messages.
    • Solution: Batch log messages, debounce frequent updates, or only emit critical status changes. Consider different event channels for high-frequency (e.g., raw agent output) vs. low-frequency (e.g., major status changes) data.
  • Agent State Consistency and Reconciliation:
    • Why it matters: The UI’s displayed status must accurately reflect the backend’s actual state. Discrepancies lead to user confusion and incorrect actions.
    • Solution: Implement a dedicated “status” event from the backend for definitive state changes (e.g., status_running, status_paused, status_completed). The frontend should primarily rely on these explicit status events, rather than inferring state from general log messages. Implement a mechanism for the frontend to query the backend for an agent’s true state on initialization or after network interruptions.
  • Graceful Pause/Resume Implementation:
    • Why it matters: A truly paused agent should release computational resources and avoid unnecessary API calls. A simple signal might not stop an agent mid-computation.
    • Solution: The agent’s task logic must be designed to be interruptible. This often involves periodically checking a should_pause flag or awaiting on a Notify at natural break points (e.g., before making an API call, after processing a chunk of data). This is a complex engineering challenge.
  • Security of Control Signals:
    • Why it matters: Malicious code injected into the frontend (though less likely in a desktop app, still a concern) shouldn’t be able to arbitrarily control agents or other system processes.
    • Solution: Tauri’s IPC is designed with security in mind, providing a strict allowlist for commands. Ensure input validation on card_id and agent_id parameters in Rust commands to prevent path traversal or other injection attacks.
  • Resource Management for Aborted Tasks:
    • Why it matters: When an agent is cancelled, its associated resources (e.g., temporary files, open network connections, spawned sub-processes) should be cleaned up to prevent resource leaks.
    • Solution: The cancel_task logic in Rust should ensure proper cleanup. For child processes, ensure they are terminated. For file system resources, delete temporary worktree data.
  • Error Reporting and User Feedback:
    • Why it matters: Users need clear, actionable feedback when an agent encounters an error or a control command fails.
    • Solution: Enhance error logs with more detail. Display user-friendly error messages in the UI, potentially with suggestions for resolution.

Common Issues & Solutions

  1. Frontend UI not updating (logs or status):
    • Issue: Logs or status changes don’t appear or update in the Svelte UI after an agent starts.
    • Solution:
      • Rust emit_all Check: In src-tauri/src/main.rs, verify that app_handle.emit_all("agent_log", &payload) is being called correctly and that the event name "agent_log" matches the frontend’s listen call exactly. Check for unwrap_or_else blocks in Rust that might be silently swallowing errors during event emission.
      • Svelte Reactivity: Ensure you are reassigning the entire object/array to trigger Svelte’s reactivity, e.g., agentLogs = agentLogs; and agentStatuses = agentStatuses;.
      • Console Logs: Add console.log(event.payload) inside your Svelte listen callback to confirm events are actually being received by the frontend.
      • Browser Dev Tools: Check the Network tab for any WebSocket traffic or errors if Tauri uses it for IPC (though for listen/invoke, it’s typically direct IPC).
  2. Control commands (Pause/Resume/Cancel) have no effect:
    • Issue: Clicking “Pause” or “Cancel” doesn’t seem to affect the agent’s execution.
    • Solution:
      • Tauri Command Registration: Double-check that pause_agent, resume_agent, and cancel_agent are correctly listed in tauri::generate_handler! in src-tauri/src/main.rs.
      • invoke Call Parameters: Verify the command name and parameters (cardId, agentId) in your Svelte invoke calls match the Rust command signatures exactly.
      • Rust AgentStateManager Logic: Debug the signal_pause, signal_resume, and signal_cancel methods within the AgentStateManager. Use println! statements to confirm they are being called and that the correct JoinHandle, Notify, or AtomicBool is being accessed.
      • Agent Task Responsiveness: For graceful control (especially pause/resume), the tokio::spawn task must be designed to periodically check for signals (e.g., AtomicBool for cancellation) or await on Notify for pausing. If the agent’s internal loop is long-running and synchronous without these checks, it won’t respond to external signals.
  3. Agent state inconsistencies in UI:
    • Issue: The UI shows an agent as “running” but it has completed, or it shows “paused” but the backend is still running (due to the simplified pause implementation).
    • Solution:
      • Explicit Status Events: Rely less on inferring status from generic log messages. Have the backend emit explicit agent_log events with log_type: "status_running", "status_completed", "status_cancelled", etc., to provide definitive state changes.
      • Cleanup in AgentStateManager: Ensure that the AgentStateManager correctly removes task entries (tasks, pause_notifies, cancel_flags) when an agent task completes or is cancelled. If not, stale entries can lead to incorrect state reporting.
      • Race Conditions: For shared mutable state in Rust, always use Arc<Mutex<T>> to prevent data races. Ensure all access to the AgentStateManager is properly locked.

Summary & Next Step

You’ve successfully enhanced Kanbots with crucial real-time feedback and control mechanisms. Users can now see live agent progress, review logs, and intervene in agent execution by pausing, resuming, or canceling tasks. This significantly improves the usability and debugging experience for orchestrating complex AI workflows.

What’s ready now:

  • The Rust backend can emit real-time log and progress events to the frontend.
  • The Rust backend can receive and process user control commands (pause, resume, cancel) via Tauri IPC.
  • The Svelte frontend dynamically displays agent status and streams logs.
  • Users can control agent execution via interactive UI buttons.

In the next chapter, we will consolidate these features by implementing robust error handling and comprehensive logging. We’ll focus on how to manage and report issues that arise from AI API calls, git operations, or agent execution, making our Kanbots application more resilient and easier to debug in a production environment.


This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.

References