feat(ui): read-only queue side strip in Mission Control
This commit is contained in:
@@ -241,7 +241,9 @@
|
|||||||
"windowTitle": "Mission Control",
|
"windowTitle": "Mission Control",
|
||||||
"clearFinished": "Erledigte entfernen",
|
"clearFinished": "Erledigte entfernen",
|
||||||
"empty": "Keine laufenden Aufgaben",
|
"empty": "Keine laufenden Aufgaben",
|
||||||
"settings": "Einstellungen"
|
"settings": "Einstellungen",
|
||||||
|
"queue": "Warteschlange",
|
||||||
|
"blocked": "Blockiert"
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
"logVisualizer": {
|
"logVisualizer": {
|
||||||
|
|||||||
@@ -241,7 +241,9 @@
|
|||||||
"windowTitle": "Mission Control",
|
"windowTitle": "Mission Control",
|
||||||
"clearFinished": "Clear finished",
|
"clearFinished": "Clear finished",
|
||||||
"empty": "No running tasks",
|
"empty": "No running tasks",
|
||||||
"settings": "Settings"
|
"settings": "Settings",
|
||||||
|
"queue": "Queue",
|
||||||
|
"blocked": "Blocked"
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
"logVisualizer": {
|
"logVisualizer": {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly IWorkerClient _worker;
|
private readonly IWorkerClient _worker;
|
||||||
private readonly Action<string, string, DateTime> _onTaskStarted;
|
private readonly Action<string, string, DateTime> _onTaskStarted;
|
||||||
|
private readonly Action<string, string, string, DateTime> _onTaskFinished;
|
||||||
|
private readonly Action<string> _onTaskUpdated;
|
||||||
private readonly Action _onConnectionRestored;
|
private readonly Action _onConnectionRestored;
|
||||||
|
|
||||||
public ObservableCollection<TaskMonitorViewModel> Monitors { get; } = new();
|
public ObservableCollection<TaskMonitorViewModel> Monitors { get; } = new();
|
||||||
@@ -42,6 +44,10 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
public bool HasMonitors => Monitors.Count > 0;
|
public bool HasMonitors => Monitors.Count > 0;
|
||||||
|
|
||||||
|
// Read-only view of the worker queue (tasks waiting to run), shown as a side strip.
|
||||||
|
public ObservableCollection<QueuedTaskViewModel> Queued { get; } = new();
|
||||||
|
public bool HasQueued => Queued.Count > 0;
|
||||||
|
|
||||||
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker)
|
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
@@ -49,13 +55,44 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
Monitors.CollectionChanged += OnMonitorsChanged;
|
Monitors.CollectionChanged += OnMonitorsChanged;
|
||||||
|
|
||||||
_onTaskStarted = (slot, taskId, startedAt) => EnsureMonitor(taskId);
|
_onTaskStarted = (slot, taskId, startedAt) => { EnsureMonitor(taskId); _ = RefreshQueueAsync(); };
|
||||||
_worker.TaskStartedEvent += _onTaskStarted;
|
_worker.TaskStartedEvent += _onTaskStarted;
|
||||||
|
|
||||||
_onConnectionRestored = SeedActive;
|
_onTaskFinished = (slot, taskId, status, finishedAt) => _ = RefreshQueueAsync();
|
||||||
|
_worker.TaskFinishedEvent += _onTaskFinished;
|
||||||
|
|
||||||
|
_onTaskUpdated = taskId => _ = RefreshQueueAsync();
|
||||||
|
_worker.TaskUpdatedEvent += _onTaskUpdated;
|
||||||
|
|
||||||
|
_onConnectionRestored = () => { SeedActive(); _ = RefreshQueueAsync(); };
|
||||||
_worker.ConnectionRestoredEvent += _onConnectionRestored;
|
_worker.ConnectionRestoredEvent += _onConnectionRestored;
|
||||||
|
|
||||||
SeedActive();
|
SeedActive();
|
||||||
|
_ = RefreshQueueAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async System.Threading.Tasks.Task RefreshQueueAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var rows = await ctx.Tasks.AsNoTracking()
|
||||||
|
.Where(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Queued)
|
||||||
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||||
|
.Select(t => new { t.Id, t.Title, t.BlockedByTaskId })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
Queued.Clear();
|
||||||
|
foreach (var r in rows)
|
||||||
|
Queued.Add(new QueuedTaskViewModel
|
||||||
|
{
|
||||||
|
Id = r.Id,
|
||||||
|
Title = r.Title ?? string.Empty,
|
||||||
|
IsBlocked = r.BlockedByTaskId != null,
|
||||||
|
});
|
||||||
|
OnPropertyChanged(nameof(HasQueued));
|
||||||
|
}
|
||||||
|
catch { /* best-effort queue refresh */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SeedActive()
|
private void SeedActive()
|
||||||
@@ -144,9 +181,19 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_worker.TaskStartedEvent -= _onTaskStarted;
|
_worker.TaskStartedEvent -= _onTaskStarted;
|
||||||
|
_worker.TaskFinishedEvent -= _onTaskFinished;
|
||||||
|
_worker.TaskUpdatedEvent -= _onTaskUpdated;
|
||||||
_worker.ConnectionRestoredEvent -= _onConnectionRestored;
|
_worker.ConnectionRestoredEvent -= _onConnectionRestored;
|
||||||
Monitors.CollectionChanged -= OnMonitorsChanged;
|
Monitors.CollectionChanged -= OnMonitorsChanged;
|
||||||
foreach (var m in Monitors) m.Dispose();
|
foreach (var m in Monitors) m.Dispose();
|
||||||
Monitors.Clear();
|
Monitors.Clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Read-only display row for a queued task in the Mission Control side strip.</summary>
|
||||||
|
public sealed class QueuedTaskViewModel
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
public required string Title { get; init; }
|
||||||
|
public bool IsBlocked { get; init; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,44 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Read-only queue strip — collapses when nothing is queued -->
|
||||||
|
<Border DockPanel.Dock="Right"
|
||||||
|
IsVisible="{Binding HasQueued}"
|
||||||
|
Width="210"
|
||||||
|
Background="{DynamicResource DeepBrush}"
|
||||||
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
|
BorderThickness="1,0,0,0">
|
||||||
|
<DockPanel LastChildFill="True" Margin="10,10">
|
||||||
|
<TextBlock DockPanel.Dock="Top" Classes="eyebrow"
|
||||||
|
Text="{loc:Tr missionControl.queue}"
|
||||||
|
Foreground="{DynamicResource TextMuteBrush}"
|
||||||
|
LetterSpacing="1.4" Margin="0,0,0,8" />
|
||||||
|
<ScrollViewer>
|
||||||
|
<ItemsControl ItemsSource="{Binding Queued}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:QueuedTaskViewModel">
|
||||||
|
<Border Margin="0,0,0,4" Padding="8,6"
|
||||||
|
Background="{DynamicResource SurfaceBrush}"
|
||||||
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
|
BorderThickness="1" CornerRadius="6">
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock Text="{Binding Title}"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
ToolTip.Tip="{Binding Title}"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}" />
|
||||||
|
<TextBlock Classes="meta"
|
||||||
|
Text="{loc:Tr missionControl.blocked}"
|
||||||
|
IsVisible="{Binding IsBlocked}"
|
||||||
|
Foreground="{DynamicResource AmberBrush}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Grid / empty state -->
|
<!-- Grid / empty state -->
|
||||||
<Panel Margin="6">
|
<Panel Margin="6">
|
||||||
<ItemsControl ItemsSource="{Binding Monitors}" IsVisible="{Binding HasMonitors}">
|
<ItemsControl ItemsSource="{Binding Monitors}" IsVisible="{Binding HasMonitors}">
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels;
|
using ClaudeDo.Ui.ViewModels;
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
@@ -214,4 +216,27 @@ public class MissionControlViewModelTests : IDisposable
|
|||||||
vm.MoveMonitor(vm.Monitors[0], vm.Monitors[2]); // move t1 to t3's slot
|
vm.MoveMonitor(vm.Monitors[0], vm.Monitors[2]); // move t1 to t3's slot
|
||||||
Assert.Equal(new[] { "t2", "t3", "t1" }, vm.Monitors.Select(m => m.SubscribedTaskId).ToArray());
|
Assert.Equal(new[] { "t2", "t3", "t1" }, vm.Monitors.Select(m => m.SubscribedTaskId).ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Queue_ReflectsQueuedTasks_InSortOrder()
|
||||||
|
{
|
||||||
|
await SeedQueueAsync();
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
using var vm = BuildVm(worker);
|
||||||
|
|
||||||
|
await vm.RefreshQueueAsync();
|
||||||
|
|
||||||
|
Assert.True(vm.HasQueued);
|
||||||
|
Assert.Equal(new[] { "first", "second" }, vm.Queued.Select(q => q.Title).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SeedQueueAsync()
|
||||||
|
{
|
||||||
|
await using var db = NewContext();
|
||||||
|
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
|
||||||
|
db.Tasks.Add(new TaskEntity { Id = "q2", ListId = "L1", Title = "second", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 1 });
|
||||||
|
db.Tasks.Add(new TaskEntity { Id = "q1", ListId = "L1", Title = "first", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
|
||||||
|
db.Tasks.Add(new TaskEntity { Id = "idle1", ListId = "L1", Title = "idle", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 2 });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user