diff --git a/backend/Controllers/DashboardController.cs b/backend/Controllers/DashboardController.cs
index 376daf4..6672ada 100644
--- a/backend/Controllers/DashboardController.cs
+++ b/backend/Controllers/DashboardController.cs
@@ -135,14 +135,52 @@ public class DashboardController(
}
///
- /// Returns the cron queue / pending tasks.
+ /// Returns aggregated queue: cron jobs + open tasks (merged, sorted by priority).
///
[HttpGet("queue")]
- public async Task> GetQueue()
+ public async Task> GetQueue(CancellationToken ct)
{
try
{
- return await gateway.GetQueueAsync();
+ // Fetch cron jobs and open tasks concurrently
+ var cronTask = gateway.GetQueueAsync();
+ var tasksTask = taskRepo.GetAllAsync(ct);
+
+ await Task.WhenAll(cronTask, tasksTask);
+
+ var cronJobs = cronTask.Result;
+ var openTasks = tasksTask.Result
+ .Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ var merged = new List();
+
+ // Map cron jobs (already in QueueItem format from gateway)
+ merged.AddRange(cronJobs);
+
+ // Map open tasks to QueueItems
+ foreach (var t in openTasks)
+ {
+ var priority = NormalizePriority(t.Priority);
+ merged.Add(new QueueItem(
+ "task-" + t.Id.ToString(),
+ t.Title,
+ t.State,
+ priority,
+ "task",
+ "--"
+ ));
+ }
+
+ // Sort: high priority first, then medium, then low
+ var priorityOrder = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["high"] = 0,
+ ["medium"] = 1,
+ ["low"] = 2
+ };
+
+ return merged.OrderBy(q => priorityOrder.GetValueOrDefault(q.Priority, 99)).ToList();
}
catch (Exception ex)
{
@@ -151,6 +189,115 @@ public class DashboardController(
}
}
+ private static string NormalizePriority(string priority)
+ {
+ return priority.ToLowerInvariant() switch
+ {
+ "high" or "critical" or "urgent" => "high",
+ "low" or "minor" => "low",
+ _ => "medium"
+ };
+ }
+
+ ///
+ /// Removes a queue item: cron jobs are deleted via gateway, tasks are set to Done.
+ ///
+ [HttpDelete("queue/{id}")]
+ public async Task DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct)
+ {
+ try
+ {
+ if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase))
+ {
+ var ok = await gateway.DeleteCronJobAsync(id);
+ if (!ok)
+ return StatusCode(502, new { error = "Gateway could not delete cron job" });
+ return NoContent();
+ }
+ else if (string.Equals(source, "task", StringComparison.OrdinalIgnoreCase))
+ {
+ // Extract the actual GUID from the prefixed id ("task-{guid}")
+ if (!id.StartsWith("task-"))
+ return BadRequest(new { error = "Invalid task id format" });
+
+ var guidStr = id["task-".Length..];
+ if (!Guid.TryParse(guidStr, out var guid))
+ return BadRequest(new { error = "Invalid task id" });
+
+ var task = await taskRepo.GetByIdAsync(guid, ct);
+ if (task is null)
+ return NotFound(new { error = "Task not found" });
+
+ // Set task status to Done instead of deleting
+ task.State = "Done";
+ await taskRepo.UpdateAsync(task, ct);
+ await activityRepo.AddAsync(new ActivityEvent
+ {
+ Type = "task",
+ Message = $"Task \"{task.Title}\" completed via queue"
+ }, ct);
+
+ return NoContent();
+ }
+
+ // Default: try cron
+ var deleted = await gateway.DeleteCronJobAsync(id);
+ if (!deleted)
+ return NotFound(new { error = "Queue item not found" });
+ return NoContent();
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Delete queue item failed for {Id}", id);
+ return StatusCode(500, new { error = "Internal error" });
+ }
+ }
+
+ ///
+ /// Changes the priority of a queue item (only for tasks; cron jobs are ignored).
+ /// Cycles: high → medium → low → high.
+ ///
+ [HttpPut("queue/{id}/priority")]
+ public async Task ChangeQueuePriority(string id, CancellationToken ct)
+ {
+ try
+ {
+ if (!id.StartsWith("task-"))
+ return Ok(new { status = "ignored", reason = "Cron job priorities are managed by the gateway" });
+
+ var guidStr = id["task-".Length..];
+ if (!Guid.TryParse(guidStr, out var guid))
+ return BadRequest(new { error = "Invalid task id" });
+
+ var task = await taskRepo.GetByIdAsync(guid, ct);
+ if (task is null)
+ return NotFound(new { error = "Task not found" });
+
+ // Cycle priority: high → medium → low → high
+ task.Priority = task.Priority.ToLowerInvariant() switch
+ {
+ "high" => "Medium",
+ "medium" => "Low",
+ "low" => "High",
+ _ => "Medium"
+ };
+
+ await taskRepo.UpdateAsync(task, ct);
+ await activityRepo.AddAsync(new ActivityEvent
+ {
+ Type = "task",
+ Message = $"Task \"{task.Title}\" priority → {task.Priority}"
+ }, ct);
+
+ return Ok(new { status = "ok", priority = task.Priority });
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Change queue priority failed for {Id}", id);
+ return StatusCode(500, new { error = "Internal error" });
+ }
+ }
+
///
/// Returns the current model and provider for a specific agent session.
/// Calls session_status with the agent's session key.
diff --git a/backend/Models/Dashboard.cs b/backend/Models/Dashboard.cs
index 6663e06..d611bff 100644
--- a/backend/Models/Dashboard.cs
+++ b/backend/Models/Dashboard.cs
@@ -47,7 +47,10 @@ public sealed record DashboardStatus(
public sealed record QueueItem(
string Id,
string Name,
- string Status
+ string Status,
+ string Priority,
+ string Source,
+ string WaitTime
);
public sealed record AgentModelInfo(
diff --git a/backend/Services/OpenClawGatewayClient.cs b/backend/Services/OpenClawGatewayClient.cs
index 98c432c..4f4cf87 100644
--- a/backend/Services/OpenClawGatewayClient.cs
+++ b/backend/Services/OpenClawGatewayClient.cs
@@ -639,7 +639,28 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
var status = j["state"]?["lastStatus"]?.GetValue()
?? j["status"]?.GetValue()
?? "unknown";
- items.Add(new QueueItem(id, name, status));
+
+ // Calculate waitTime from nextRun if available
+ var waitTime = "--";
+ var nextRunStr = j["nextRun"]?.GetValue()
+ ?? j["next_run"]?.GetValue()
+ ?? j["scheduledAt"]?.GetValue();
+ if (nextRunStr is not null && DateTimeOffset.TryParse(nextRunStr, out var nextRun))
+ {
+ var diff = nextRun - DateTimeOffset.UtcNow;
+ if (diff.TotalMinutes < 0)
+ waitTime = "now";
+ else if (diff.TotalMinutes < 1)
+ waitTime = "<1m";
+ else if (diff.TotalMinutes < 60)
+ waitTime = $"{(int)diff.TotalMinutes}m";
+ else if (diff.TotalHours < 24)
+ waitTime = $"{(int)diff.TotalHours}h";
+ else
+ waitTime = $"{(int)diff.TotalDays}d";
+ }
+
+ items.Add(new QueueItem(id, name, status, "medium", "cron", waitTime));
}
return items;
}
@@ -649,6 +670,19 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
}
}
+ public async Task DeleteCronJobAsync(string id)
+ {
+ try
+ {
+ var result = await InvokeToolAsync("cron", new { action = "delete", id });
+ return result is not null;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
public async Task GetStatusAsync()
{
var gatewayOk = false;
diff --git a/frontend/src/components/dashboard/QueuePanel.vue b/frontend/src/components/dashboard/QueuePanel.vue
index 19af225..1b2941d 100644
--- a/frontend/src/components/dashboard/QueuePanel.vue
+++ b/frontend/src/components/dashboard/QueuePanel.vue
@@ -8,6 +8,7 @@ import {
ArrowDown,
Trash2,
Zap,
+ RefreshCw,
} from '@lucide/vue'
import type { QueueItem } from '../../composables/useDashboardData'
import Button from '@/components/ui/button/Button.vue'
@@ -107,6 +108,7 @@ function onDragEnd(): void {
>
+
{{ item.source }}
-