feat(ui): read-only queue side strip in Mission Control

This commit is contained in:
Mika Kuns
2026-06-25 17:02:43 +02:00
parent 1c94fbdb14
commit 9eb54a0d2f
5 changed files with 118 additions and 4 deletions

View File

@@ -241,7 +241,9 @@
"windowTitle": "Mission Control",
"clearFinished": "Erledigte entfernen",
"empty": "Keine laufenden Aufgaben",
"settings": "Einstellungen"
"settings": "Einstellungen",
"queue": "Warteschlange",
"blocked": "Blockiert"
},
"modals": {
"logVisualizer": {

View File

@@ -241,7 +241,9 @@
"windowTitle": "Mission Control",
"clearFinished": "Clear finished",
"empty": "No running tasks",
"settings": "Settings"
"settings": "Settings",
"queue": "Queue",
"blocked": "Blocked"
},
"modals": {
"logVisualizer": {

View File

@@ -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; }
}

View File

@@ -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}">

View File

@@ -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();
}
}