From 22e70ffcb477dfc568b1afe3422d1e6c1170104e Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 28 Aug 2026 10:31:25 +0200 Subject: [PATCH] feat(ui): Mission Control als Session-Hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "+" ö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) --- src/ClaudeDo.Localization/locales/de.json | 7 +- src/ClaudeDo.Localization/locales/en.json | 7 +- .../ViewModels/MissionControlViewModel.cs | 101 ++++++++++++++++-- .../MissionControl/MissionControlView.axaml | 39 ++++--- .../MissionControlView.axaml.cs | 7 +- .../MissionControlViewModelTests.cs | 60 +++++++++++ 6 files changed, 197 insertions(+), 24 deletions(-) diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index f9221c04..04134132 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -320,9 +320,14 @@ "newSession": "Neue Sitzung", "empty": "Keine laufenden Aufgaben", "settings": "Einstellungen", - "queue": "Warteschlange", + "activity": "Aktivität", "blocked": "Blockiert", "running": "Läuft", + "review": "Im Review", + "failed": "Fehlgeschlagen", + "roadblock": "Blockade gemeldet", + "openSession": "Sitzung öffnen", + "browseForFolder": "Ordner wählen…", "focusMode": "Fokus", "overviewMode": "Übersicht", "closeSession": "Sitzung beenden", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index a76e6fd6..5f0ba829 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -320,9 +320,14 @@ "newSession": "New session", "empty": "No running tasks", "settings": "Settings", - "queue": "Queue", + "activity": "Activity", "blocked": "Blocked", "running": "Running", + "review": "In review", + "failed": "Failed", + "roadblock": "Roadblock reported", + "openSession": "Open session", + "browseForFolder": "Choose folder…", "focusMode": "Focus", "overviewMode": "Overview", "closeSession": "Kill session", diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index 54479765..ab7d1ffd 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -58,10 +58,20 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable 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 Queued { get; } = new(); 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 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 MissionControlViewModel(IDbContextFactory dbFactory, IWorkerClient worker, UsagePillViewModel usagePill) @@ -89,6 +99,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable _worker.HandoffRequestedEvent += _onHandoffRequested; _ = RefreshQueueAsync(); + _ = RefreshSessionTargetsAsync(); } 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(); var rows = await ctx.Tasks.AsNoTracking() .Where(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Queued - || t.Status == ClaudeDo.Data.Models.TaskStatus.Running) - .OrderBy(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Running ? 0 : 1) + || t.Status == ClaudeDo.Data.Models.TaskStatus.Running + || 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) - .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(); Queued.Clear(); foreach (var r in rows) { 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 { Id = id, Title = r.Title ?? string.Empty, - IsBlocked = r.BlockedByTaskId != null, - IsRunning = r.Status == ClaudeDo.Data.Models.TaskStatus.Running, + IsRunning = running, + TintKey = tint, + StatusColorKey = colorKey, + StatusLabel = labelKey is null ? null : Loc.T(labelKey), OpenInAppCommand = new RelayCommand(() => OpenInApp?.Invoke(id)), + OpenSessionCommand = new RelayCommand(() => _ = OpenConPtySessionAsync(id)), }); } 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)); } } + // Row tint + status label for the activity strip. Keys are resolved to brushes by + // DotBrushConverter ("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 // 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 @@ -445,12 +514,28 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable } } -/// Read-only display row for a queued or running task in the Mission Control side strip. +/// 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. public sealed class QueuedTaskViewModel { public required string Id { get; init; } public required string Title { get; init; } - public bool IsBlocked { get; init; } public bool IsRunning { get; init; } + /// Brush key of the row tint (e.g. "RunningTint"); null = untinted. + public string? TintKey { get; init; } + public string? TintBorderKey => TintKey is null ? null : TintKey + "Border"; + public string? StatusLabel { get; init; } + public string? StatusColorKey { get; init; } + /// A running task's worktree belongs to the autonomous run — never open a second, + /// interactive claude process into it. + public bool CanOpenSession => !IsRunning; public IRelayCommand? OpenInAppCommand { get; init; } + public IRelayCommand? OpenSessionCommand { get; init; } +} + +/// One entry in the "new session" menu: a configured list's working dir, or browse. +public sealed class SessionTargetViewModel +{ + public required string Label { get; init; } + public required IRelayCommand StartCommand { get; init; } } diff --git a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml index b4c6302b..f244a4a9 100644 --- a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml +++ b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml @@ -4,6 +4,7 @@ xmlns:vmm="using:ClaudeDo.Ui.ViewModels.MissionControl" xmlns:mc="using:ClaudeDo.Ui.Views.MissionControl" xmlns:controls="using:ClaudeDo.Ui.Views.Controls" + xmlns:conv="using:ClaudeDo.Ui.Converters" xmlns:loc="using:ClaudeDo.Ui.Localization" x:DataType="vm:MissionControlViewModel" x:Class="ClaudeDo.Ui.Views.MissionControl.MissionControlView"> @@ -33,9 +34,19 @@ Margin="0,0,4,0" VerticalAlignment="Center"/> diff --git a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs index 67197117..645cb7b1 100644 --- a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs @@ -3,7 +3,6 @@ using System.Collections.Specialized; using System.ComponentModel; using System.Linq; using Avalonia.Controls; -using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Platform.Storage; @@ -30,8 +29,10 @@ public partial class MissionControlView : UserControl 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. - private async void OnNewSessionClicked(object? sender, RoutedEventArgs e) + private async void BrowseForSessionAsync() { if (DataContext is not MissionControlViewModel vm) return; var topLevel = TopLevel.GetTopLevel(this); @@ -53,6 +54,7 @@ public partial class MissionControlView : UserControl { _vm.Panes.CollectionChanged -= OnPanesChanged; _vm.PropertyChanged -= OnViewModelPropertyChanged; + _vm.BrowseForSessionRequested = null; } _vm = DataContext as MissionControlViewModel; @@ -61,6 +63,7 @@ public partial class MissionControlView : UserControl { _vm.Panes.CollectionChanged += OnPanesChanged; _vm.PropertyChanged += OnViewModelPropertyChanged; + _vm.BrowseForSessionRequested = BrowseForSessionAsync; } RebuildOverviewGrid(); diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs index df76b135..5b395df3 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs @@ -202,6 +202,66 @@ public class MissionControlViewModelTests : IDisposable 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() { await using var db = NewContext();