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:
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user