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
tokiofor asynchronous task management andstd::syncprimitives 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:
- 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.
- Dynamic Agent Status: The UI will reflect the current state of an agent (e.g., “idle”, “running”, “paused”, “cancelled”, “completed”).
- User Control Buttons: Implement “Run,” “Pause,” “Resume,” and “Cancel” buttons for each agent, allowing direct user intervention.
- 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
- 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. - 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.
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 dependenciesapp_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. TheAgentLogstruct 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 dependenciesNow, 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 usesArc<Mutex<HashMap<...>>>to safely storeJoinHandles,Notifyobjects, andAtomicBoolflags.Arc<T>: Enables multiple owners of the same data, crucial for sharing state between theAgentStateManagerand the spawnedtokio::task.Mutex<T>: Provides exclusive access to theHashMaps, 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().awaitwill block untilnotify.notify_one()is called. This is the foundation for pause/resume.
tauri::State<'_, AppState>: This allows our Tauri commands to access the sharedAppStateinstance, which holds ourAgentStateManager.tokio::spawn: Eachrun_agent_tasknow 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. Thesignal_cancelfunction also callshandle.abort()as a forceful backup. - Pause/Resume Caveat: The current
signal_pauseonly 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 apause_agentcommand is issued. Thesignal_resumecommand would then callnotify.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>onMountandlisten: TheKanbanCardcomponent sets up an event listener foragent_logevents from the Tauri backend when it mounts.agentLogsandagentStatuses: These Svelte reactive variables store the streaming logs and the current status for each agent on the card, keyed byagent.id.invokefor Control: Functions likerunAgent,pauseAgent,resumeAgent, andcancelAgentuseinvoketo 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
divwithoverflow-y: autois used to display agent logs. ThescrollToBottomfunction 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;andagentStatuses = 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.
- Start the Tauri App:
npm run tauri dev - 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.
- 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”.
- 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.
- 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”.
- The status should change to “cancelled”, and a log entry like
- 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 theoutput.jsfile generated by the agent.
- After an agent completes successfully, check your
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_pauseflag orawaiting on aNotifyat 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_idandagent_idparameters 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_tasklogic 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
- 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_allCheck: Insrc-tauri/src/main.rs, verify thatapp_handle.emit_all("agent_log", &payload)is being called correctly and that the event name"agent_log"matches the frontend’slistencall exactly. Check forunwrap_or_elseblocks 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;andagentStatuses = agentStatuses;. - Console Logs: Add
console.log(event.payload)inside your Sveltelistencallback 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).
- Rust
- 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, andcancel_agentare correctly listed intauri::generate_handler!insrc-tauri/src/main.rs. invokeCall Parameters: Verify the command name and parameters (cardId,agentId) in your Svelteinvokecalls match the Rust command signatures exactly.- Rust
AgentStateManagerLogic: Debug thesignal_pause,signal_resume, andsignal_cancelmethods within theAgentStateManager. Useprintln!statements to confirm they are being called and that the correctJoinHandle,Notify, orAtomicBoolis being accessed. - Agent Task Responsiveness: For graceful control (especially pause/resume), the
tokio::spawntask must be designed to periodically check for signals (e.g.,AtomicBoolfor cancellation) orawaitonNotifyfor pausing. If the agent’s internal loop is long-running and synchronous without these checks, it won’t respond to external signals.
- Tauri Command Registration: Double-check that
- 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_logevents withlog_type: "status_running","status_completed","status_cancelled", etc., to provide definitive state changes. - Cleanup in
AgentStateManager: Ensure that theAgentStateManagercorrectly 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 theAgentStateManageris properly locked.
- Explicit Status Events: Rely less on inferring status from generic log messages. Have the backend emit explicit
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.