feat(ui): Mission Control als Session-Hub

- "+" öffnet ein Menü mit allen Listen (working dir) statt direkt den
  Ordner-Dialog; "Ordner wählen…" bleibt als Fallback erste Zeile, damit
  das Menü auch bei fehlgeschlagener Listen-Abfrage nutzbar bleibt
- Rail-Rows: Kontextmenü "Sitzung öffnen" (gesperrt für Running — der
  Worktree gehört dort dem autonomen Lauf)
- Aktivitäts-Strip statt reiner Queue: WaitingForReview/Failed/Roadblock
  kommen dazu, Attention zuerst, unterschieden nur über Farbe (Aktionen
  bleiben in der Task-Detailansicht)
This commit is contained in:
mika kuns
2026-08-28 14:17:38 +02:00
parent 8d597df448
commit 22e70ffcb4
6 changed files with 197 additions and 24 deletions
+6 -1
View File
@@ -320,9 +320,14 @@
"newSession": "Neue Sitzung", "newSession": "Neue Sitzung",
"empty": "Keine laufenden Aufgaben", "empty": "Keine laufenden Aufgaben",
"settings": "Einstellungen", "settings": "Einstellungen",
"queue": "Warteschlange", "activity": "Aktivität",
"blocked": "Blockiert", "blocked": "Blockiert",
"running": "Läuft", "running": "Läuft",
"review": "Im Review",
"failed": "Fehlgeschlagen",
"roadblock": "Blockade gemeldet",
"openSession": "Sitzung öffnen",
"browseForFolder": "Ordner wählen…",
"focusMode": "Fokus", "focusMode": "Fokus",
"overviewMode": "Übersicht", "overviewMode": "Übersicht",
"closeSession": "Sitzung beenden", "closeSession": "Sitzung beenden",
+6 -1
View File
@@ -320,9 +320,14 @@
"newSession": "New session", "newSession": "New session",
"empty": "No running tasks", "empty": "No running tasks",
"settings": "Settings", "settings": "Settings",
"queue": "Queue", "activity": "Activity",
"blocked": "Blocked", "blocked": "Blocked",
"running": "Running", "running": "Running",
"review": "In review",
"failed": "Failed",
"roadblock": "Roadblock reported",
"openSession": "Open session",
"browseForFolder": "Choose folder…",
"focusMode": "Focus", "focusMode": "Focus",
"overviewMode": "Overview", "overviewMode": "Overview",
"closeSession": "Kill session", "closeSession": "Kill session",
@@ -58,10 +58,20 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
public bool HasPanes => Panes.Count > 0; public bool HasPanes => Panes.Count > 0;
// Read-only view of the worker queue (tasks waiting to run), shown as a side strip. // Read-only activity strip. Beyond the queue itself (Queued/Running) it also carries the rows
// that want the user's attention (Failed, WaitingForReview, roadblocked) — colour-coded only;
// clicking a row opens the task in the main app, where the actual actions live.
public ObservableCollection<QueuedTaskViewModel> Queued { get; } = new(); public ObservableCollection<QueuedTaskViewModel> Queued { get; } = new();
public bool HasQueued => Queued.Count > 0; public bool HasQueued => Queued.Count > 0;
// "New session" targets: one entry per list with a working dir, plus a browse fallback, so a
// fresh session is two clicks instead of navigating a folder picker.
public ObservableCollection<SessionTargetViewModel> SessionTargets { get; } = new();
// View-layer seam: the folder picker needs a TopLevel, so the view owns it (see
// MissionControlView.OnNewSessionClicked).
public Action? BrowseForSessionRequested { get; set; }
public UsagePillViewModel UsagePill { get; } public UsagePillViewModel UsagePill { get; }
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, UsagePillViewModel usagePill) public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, UsagePillViewModel usagePill)
@@ -89,6 +99,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
_worker.HandoffRequestedEvent += _onHandoffRequested; _worker.HandoffRequestedEvent += _onHandoffRequested;
_ = RefreshQueueAsync(); _ = RefreshQueueAsync();
_ = RefreshSessionTargetsAsync();
} }
internal async System.Threading.Tasks.Task RefreshQueueAsync() internal async System.Threading.Tasks.Task RefreshQueueAsync()
@@ -98,23 +109,33 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
await using var ctx = await _dbFactory.CreateDbContextAsync(); await using var ctx = await _dbFactory.CreateDbContextAsync();
var rows = await ctx.Tasks.AsNoTracking() var rows = await ctx.Tasks.AsNoTracking()
.Where(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Queued .Where(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Queued
|| t.Status == ClaudeDo.Data.Models.TaskStatus.Running) || t.Status == ClaudeDo.Data.Models.TaskStatus.Running
.OrderBy(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Running ? 0 : 1) || t.Status == ClaudeDo.Data.Models.TaskStatus.WaitingForReview
|| t.Status == ClaudeDo.Data.Models.TaskStatus.Failed)
// Attention first (failed, then review), then the queue itself.
.OrderBy(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Failed ? 0
: t.Status == ClaudeDo.Data.Models.TaskStatus.WaitingForReview ? 1
: t.Status == ClaudeDo.Data.Models.TaskStatus.Running ? 2 : 3)
.ThenBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .ThenBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.Select(t => new { t.Id, t.Title, t.BlockedByTaskId, t.Status }) .Select(t => new { t.Id, t.Title, t.BlockedByTaskId, t.Status, t.RoadblockCount })
.ToListAsync(); .ToListAsync();
Queued.Clear(); Queued.Clear();
foreach (var r in rows) foreach (var r in rows)
{ {
var id = r.Id; var id = r.Id;
var running = r.Status == ClaudeDo.Data.Models.TaskStatus.Running;
var (tint, colorKey, labelKey) = Decorate(r.Status, r.RoadblockCount, r.BlockedByTaskId != null);
Queued.Add(new QueuedTaskViewModel Queued.Add(new QueuedTaskViewModel
{ {
Id = id, Id = id,
Title = r.Title ?? string.Empty, Title = r.Title ?? string.Empty,
IsBlocked = r.BlockedByTaskId != null, IsRunning = running,
IsRunning = r.Status == ClaudeDo.Data.Models.TaskStatus.Running, TintKey = tint,
StatusColorKey = colorKey,
StatusLabel = labelKey is null ? null : Loc.T(labelKey),
OpenInAppCommand = new RelayCommand(() => OpenInApp?.Invoke(id)), OpenInAppCommand = new RelayCommand(() => OpenInApp?.Invoke(id)),
OpenSessionCommand = new RelayCommand(() => _ = OpenConPtySessionAsync(id)),
}); });
} }
OnPropertyChanged(nameof(HasQueued)); OnPropertyChanged(nameof(HasQueued));
@@ -122,6 +143,54 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); } catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
} }
// Row tint + status label for the activity strip. Keys are resolved to brushes by
// DotBrushConverter ("<key>Brush"), so they must match tokens in Tokens.axaml.
private static (string? Tint, string? ColorKey, string? LabelKey) Decorate(
ClaudeDo.Data.Models.TaskStatus status, int roadblockCount, bool blocked)
=> status switch
{
ClaudeDo.Data.Models.TaskStatus.Failed => ("ErrorTint", "StatusError", "missionControl.failed"),
ClaudeDo.Data.Models.TaskStatus.WaitingForReview when roadblockCount > 0
=> ("RoadblockTint", "Amber", "missionControl.roadblock"),
ClaudeDo.Data.Models.TaskStatus.WaitingForReview => ("DoneTint", "StatusReview", "missionControl.review"),
ClaudeDo.Data.Models.TaskStatus.Running => ("RunningTint", "StatusRunning", "missionControl.running"),
_ when blocked => (null, "Amber", "missionControl.blocked"),
_ => (null, null, null),
};
// Rebuilt on every flyout open — lists change rarely, but a stale target would launch a
// session in a directory that is no longer configured.
internal async System.Threading.Tasks.Task RefreshSessionTargetsAsync()
{
SessionTargets.Clear();
// Browse goes in first so the menu stays usable even if the list query below fails.
SessionTargets.Add(new SessionTargetViewModel
{
Label = Loc.T("missionControl.browseForFolder"),
StartCommand = new RelayCommand(() => BrowseForSessionRequested?.Invoke()),
});
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = await ctx.Lists.AsNoTracking()
.Where(l => l.WorkingDir != null && l.WorkingDir != "")
.OrderBy(l => l.SortOrder).ThenBy(l => l.Name)
.Select(l => new { l.Name, l.WorkingDir })
.ToListAsync();
foreach (var l in lists)
{
var dir = l.WorkingDir!;
SessionTargets.Add(new SessionTargetViewModel
{
Label = l.Name,
StartCommand = new RelayCommand(() => _ = OpenAdHocConPtySessionAsync(dir)),
});
}
}
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
}
// Drop-to-queue: a task dragged from the main app onto Mission Control gets queued. Goes // Drop-to-queue: a task dragged from the main app onto Mission Control gets queued. Goes
// through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write so the // through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write so the
// manual/draft-child guards apply here too. The interactive-session check has to live here // manual/draft-child guards apply here too. The interactive-session check has to live here
@@ -445,12 +514,28 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
} }
} }
/// <summary>Read-only display row for a queued or running task in the Mission Control side strip.</summary> /// <summary>Display row in the Mission Control activity strip (queued, running, or wanting
/// attention). Read-only apart from opening the task in the app / in a session.</summary>
public sealed class QueuedTaskViewModel public sealed class QueuedTaskViewModel
{ {
public required string Id { get; init; } public required string Id { get; init; }
public required string Title { get; init; } public required string Title { get; init; }
public bool IsBlocked { get; init; }
public bool IsRunning { get; init; } public bool IsRunning { get; init; }
/// <summary>Brush key of the row tint (e.g. "RunningTint"); null = untinted.</summary>
public string? TintKey { get; init; }
public string? TintBorderKey => TintKey is null ? null : TintKey + "Border";
public string? StatusLabel { get; init; }
public string? StatusColorKey { get; init; }
/// <summary>A running task's worktree belongs to the autonomous run — never open a second,
/// interactive claude process into it.</summary>
public bool CanOpenSession => !IsRunning;
public IRelayCommand? OpenInAppCommand { get; init; } public IRelayCommand? OpenInAppCommand { get; init; }
public IRelayCommand? OpenSessionCommand { get; init; }
}
/// <summary>One entry in the "new session" menu: a configured list's working dir, or browse.</summary>
public sealed class SessionTargetViewModel
{
public required string Label { get; init; }
public required IRelayCommand StartCommand { get; init; }
} }
@@ -4,6 +4,7 @@
xmlns:vmm="using:ClaudeDo.Ui.ViewModels.MissionControl" xmlns:vmm="using:ClaudeDo.Ui.ViewModels.MissionControl"
xmlns:mc="using:ClaudeDo.Ui.Views.MissionControl" xmlns:mc="using:ClaudeDo.Ui.Views.MissionControl"
xmlns:controls="using:ClaudeDo.Ui.Views.Controls" xmlns:controls="using:ClaudeDo.Ui.Views.Controls"
xmlns:conv="using:ClaudeDo.Ui.Converters"
xmlns:loc="using:ClaudeDo.Ui.Localization" xmlns:loc="using:ClaudeDo.Ui.Localization"
x:DataType="vm:MissionControlViewModel" x:DataType="vm:MissionControlViewModel"
x:Class="ClaudeDo.Ui.Views.MissionControl.MissionControlView"> x:Class="ClaudeDo.Ui.Views.MissionControl.MissionControlView">
@@ -33,9 +34,19 @@
Margin="0,0,4,0" Margin="0,0,4,0"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<Button Classes="icon-btn" <Button Classes="icon-btn"
Click="OnNewSessionClicked"
ToolTip.Tip="{loc:Tr missionControl.newSession}"> ToolTip.Tip="{loc:Tr missionControl.newSession}">
<PathIcon Data="{StaticResource Icon.Plus}" Width="15" Height="15"/> <PathIcon Data="{StaticResource Icon.Plus}" Width="15" Height="15"/>
<Button.Flyout>
<MenuFlyout ItemsSource="{Binding SessionTargets}" Opening="OnSessionFlyoutOpening">
<MenuFlyout.ItemContainerTheme>
<ControlTheme TargetType="MenuItem" BasedOn="{StaticResource {x:Type MenuItem}}"
x:DataType="vm:SessionTargetViewModel">
<Setter Property="Header" Value="{Binding Label}"/>
<Setter Property="Command" Value="{Binding StartCommand}"/>
</ControlTheme>
</MenuFlyout.ItemContainerTheme>
</MenuFlyout>
</Button.Flyout>
</Button> </Button>
<Button Classes="icon-btn" <Button Classes="icon-btn"
Command="{Binding ToggleLayoutCommand}" Command="{Binding ToggleLayoutCommand}"
@@ -100,7 +111,7 @@
BorderThickness="1,0,0,0"> BorderThickness="1,0,0,0">
<DockPanel LastChildFill="True" Margin="10,10"> <DockPanel LastChildFill="True" Margin="10,10">
<TextBlock DockPanel.Dock="Top" Classes="eyebrow" <TextBlock DockPanel.Dock="Top" Classes="eyebrow"
Text="{loc:Tr missionControl.queue}" Text="{loc:Tr missionControl.activity}"
Foreground="{DynamicResource TextMuteBrush}" Foreground="{DynamicResource TextMuteBrush}"
LetterSpacing="1.4" Margin="0,0,0,8" /> LetterSpacing="1.4" Margin="0,0,0,8" />
<ScrollViewer> <ScrollViewer>
@@ -112,27 +123,31 @@
Background="Transparent" BorderThickness="0" Background="Transparent" BorderThickness="0"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch" HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
Cursor="Hand"> Cursor="Hand">
<Button.ContextMenu>
<ContextMenu>
<MenuItem Header="{loc:Tr missionControl.openSession}"
Command="{Binding OpenSessionCommand}"
IsEnabled="{Binding CanOpenSession}" />
</ContextMenu>
</Button.ContextMenu>
<Panel> <Panel>
<Border Background="{DynamicResource SurfaceBrush}" <Border Background="{DynamicResource SurfaceBrush}"
BorderBrush="{DynamicResource LineBrush}" BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1" CornerRadius="6" /> BorderThickness="1" CornerRadius="6" />
<Border Background="{DynamicResource RunningTintBrush}" <!-- Single state tint; brush keys come from the VM (Tokens.axaml). -->
BorderBrush="{DynamicResource RunningTintBorderBrush}" <Border Background="{Binding TintKey, Converter={x:Static conv:DotBrushConverter.Instance}}"
BorderBrush="{Binding TintBorderKey, Converter={x:Static conv:DotBrushConverter.Instance}}"
BorderThickness="1" CornerRadius="6" BorderThickness="1" CornerRadius="6"
IsVisible="{Binding IsRunning}" /> IsVisible="{Binding TintKey, Converter={x:Static ObjectConverters.IsNotNull}}" />
<StackPanel Margin="8,6" Spacing="2"> <StackPanel Margin="8,6" Spacing="2">
<TextBlock Text="{Binding Title}" <TextBlock Text="{Binding Title}"
TextTrimming="CharacterEllipsis" TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Title}" ToolTip.Tip="{Binding Title}"
Foreground="{DynamicResource TextDimBrush}" /> Foreground="{DynamicResource TextDimBrush}" />
<TextBlock Classes="meta" <TextBlock Classes="meta"
Text="{loc:Tr missionControl.blocked}" Text="{Binding StatusLabel}"
IsVisible="{Binding IsBlocked}" IsVisible="{Binding StatusLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Foreground="{DynamicResource AmberBrush}" /> Foreground="{Binding StatusColorKey, Converter={x:Static conv:DotBrushConverter.Instance}}" />
<TextBlock Classes="meta"
Text="{loc:Tr missionControl.running}"
IsVisible="{Binding IsRunning}"
Foreground="{DynamicResource StatusRunningBrush}" />
</StackPanel> </StackPanel>
</Panel> </Panel>
</Button> </Button>
@@ -3,7 +3,6 @@ using System.Collections.Specialized;
using System.ComponentModel; using System.ComponentModel;
using System.Linq; using System.Linq;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Layout; using Avalonia.Layout;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
@@ -30,8 +29,10 @@ public partial class MissionControlView : UserControl
QueueSplitter.DragCompleted += (_, _) => _queueWidth = BodyGrid.ColumnDefinitions[2].Width; QueueSplitter.DragCompleted += (_, _) => _queueWidth = BodyGrid.ColumnDefinitions[2].Width;
} }
private void OnSessionFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshSessionTargetsAsync();
// Ad-hoc ConPTY session: the view owns the folder picker, the VM only takes the chosen path. // Ad-hoc ConPTY session: the view owns the folder picker, the VM only takes the chosen path.
private async void OnNewSessionClicked(object? sender, RoutedEventArgs e) private async void BrowseForSessionAsync()
{ {
if (DataContext is not MissionControlViewModel vm) return; if (DataContext is not MissionControlViewModel vm) return;
var topLevel = TopLevel.GetTopLevel(this); var topLevel = TopLevel.GetTopLevel(this);
@@ -53,6 +54,7 @@ public partial class MissionControlView : UserControl
{ {
_vm.Panes.CollectionChanged -= OnPanesChanged; _vm.Panes.CollectionChanged -= OnPanesChanged;
_vm.PropertyChanged -= OnViewModelPropertyChanged; _vm.PropertyChanged -= OnViewModelPropertyChanged;
_vm.BrowseForSessionRequested = null;
} }
_vm = DataContext as MissionControlViewModel; _vm = DataContext as MissionControlViewModel;
@@ -61,6 +63,7 @@ public partial class MissionControlView : UserControl
{ {
_vm.Panes.CollectionChanged += OnPanesChanged; _vm.Panes.CollectionChanged += OnPanesChanged;
_vm.PropertyChanged += OnViewModelPropertyChanged; _vm.PropertyChanged += OnViewModelPropertyChanged;
_vm.BrowseForSessionRequested = BrowseForSessionAsync;
} }
RebuildOverviewGrid(); RebuildOverviewGrid();
@@ -202,6 +202,66 @@ public class MissionControlViewModelTests : IDisposable
Assert.Equal(new[] { "first", "second" }, vm.Queued.Select(q => q.Title).ToArray()); Assert.Equal(new[] { "first", "second" }, vm.Queued.Select(q => q.Title).ToArray());
} }
// ── activity strip: attention rows come first and carry a tint ─────────────
[Fact]
public async Task RefreshQueueAsync_IncludesAttentionRows_AttentionFirst_WithTint()
{
await using (var db = NewContext())
{
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "q1", ListId = "L1", Title = "queued", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "run1", ListId = "L1", Title = "running", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 1 });
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "rev1", ListId = "L1", Title = "review", Status = TaskStatus.WaitingForReview, CreatedAt = DateTime.UtcNow, SortOrder = 2 });
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "rb1", ListId = "L1", Title = "roadblocked", Status = TaskStatus.WaitingForReview, RoadblockCount = 2, CreatedAt = DateTime.UtcNow, SortOrder = 3 });
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "f1", ListId = "L1", Title = "failed", Status = TaskStatus.Failed, CreatedAt = DateTime.UtcNow, SortOrder = 4 });
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "done1", ListId = "L1", Title = "done", Status = TaskStatus.Done, CreatedAt = DateTime.UtcNow, SortOrder = 5 });
await db.SaveChangesAsync();
}
using var vm = BuildVm(new FakeWorker());
await vm.RefreshQueueAsync();
// Done is not activity; the other five are, ordered failed → review → running → queued.
Assert.Equal(new[] { "f1", "rev1", "rb1", "run1", "q1" }, vm.Queued.Select(q => q.Id).ToArray());
Assert.Equal("ErrorTint", vm.Queued[0].TintKey);
Assert.Equal("DoneTint", vm.Queued[1].TintKey);
Assert.Equal("RoadblockTint", vm.Queued[2].TintKey); // roadblocks outrank plain review
Assert.Equal("RunningTint", vm.Queued[3].TintKey);
Assert.Null(vm.Queued[4].TintKey);
Assert.Equal("RunningTintBorder", vm.Queued[3].TintBorderKey);
// A running task's worktree belongs to the autonomous run — no interactive session on it.
Assert.False(vm.Queued[3].CanOpenSession);
Assert.True(vm.Queued[4].CanOpenSession);
}
[Fact]
public async Task RefreshSessionTargetsAsync_ListsWithWorkingDir_PlusBrowse()
{
await using (var db = NewContext())
{
db.Lists.Add(new ListEntity { Id = "L1", Name = "Alpha", WorkingDir = @"C:\repos\alpha", CreatedAt = DateTime.UtcNow, SortOrder = 1 });
db.Lists.Add(new ListEntity { Id = "L2", Name = "Beta", WorkingDir = @"C:\repos\beta", CreatedAt = DateTime.UtcNow, SortOrder = 0 });
db.Lists.Add(new ListEntity { Id = "L3", Name = "NoDir", CreatedAt = DateTime.UtcNow, SortOrder = 2 });
await db.SaveChangesAsync();
}
using var vm = BuildVm(new FakeWorker());
var browsed = false;
vm.BrowseForSessionRequested = () => browsed = true;
await vm.RefreshSessionTargetsAsync();
// Browse first (survives a failing list query), then the lists in sort order.
Assert.Equal(3, vm.SessionTargets.Count);
Assert.Equal(new[] { "Beta", "Alpha" }, vm.SessionTargets.Skip(1).Select(t => t.Label).ToArray());
vm.SessionTargets[0].StartCommand.Execute(null);
Assert.True(browsed);
}
private async Task SeedQueueAsync() private async Task SeedQueueAsync()
{ {
await using var db = NewContext(); await using var db = NewContext();