feat(ui): read-only queue side strip in Mission Control
This commit is contained in:
@@ -241,7 +241,9 @@
|
||||
"windowTitle": "Mission Control",
|
||||
"clearFinished": "Erledigte entfernen",
|
||||
"empty": "Keine laufenden Aufgaben",
|
||||
"settings": "Einstellungen"
|
||||
"settings": "Einstellungen",
|
||||
"queue": "Warteschlange",
|
||||
"blocked": "Blockiert"
|
||||
},
|
||||
"modals": {
|
||||
"logVisualizer": {
|
||||
|
||||
@@ -241,7 +241,9 @@
|
||||
"windowTitle": "Mission Control",
|
||||
"clearFinished": "Clear finished",
|
||||
"empty": "No running tasks",
|
||||
"settings": "Settings"
|
||||
"settings": "Settings",
|
||||
"queue": "Queue",
|
||||
"blocked": "Blocked"
|
||||
},
|
||||
"modals": {
|
||||
"logVisualizer": {
|
||||
|
||||
@@ -16,6 +16,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly Action<string, string, DateTime> _onTaskStarted;
|
||||
private readonly Action<string, string, string, DateTime> _onTaskFinished;
|
||||
private readonly Action<string> _onTaskUpdated;
|
||||
private readonly Action _onConnectionRestored;
|
||||
|
||||
public ObservableCollection<TaskMonitorViewModel> Monitors { get; } = new();
|
||||
@@ -42,6 +44,10 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
|
||||
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)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
@@ -49,13 +55,44 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
|
||||
Monitors.CollectionChanged += OnMonitorsChanged;
|
||||
|
||||
_onTaskStarted = (slot, taskId, startedAt) => EnsureMonitor(taskId);
|
||||
_onTaskStarted = (slot, taskId, startedAt) => { EnsureMonitor(taskId); _ = RefreshQueueAsync(); };
|
||||
_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;
|
||||
|
||||
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()
|
||||
@@ -144,9 +181,19 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
public void Dispose()
|
||||
{
|
||||
_worker.TaskStartedEvent -= _onTaskStarted;
|
||||
_worker.TaskFinishedEvent -= _onTaskFinished;
|
||||
_worker.TaskUpdatedEvent -= _onTaskUpdated;
|
||||
_worker.ConnectionRestoredEvent -= _onConnectionRestored;
|
||||
Monitors.CollectionChanged -= OnMonitorsChanged;
|
||||
foreach (var m in Monitors) m.Dispose();
|
||||
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>
|
||||
</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 -->
|
||||
<Panel Margin="6">
|
||||
<ItemsControl ItemsSource="{Binding Monitors}" IsVisible="{Binding HasMonitors}">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using System.Linq;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
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
|
||||
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