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
@@ -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<QueuedTaskViewModel> 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<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 MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> 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 ("<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
// 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
}
}
/// <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 required string Id { get; init; }
public required string Title { get; init; }
public bool IsBlocked { 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? 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; }
}