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

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