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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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"/>
|
||||
<Button Classes="icon-btn"
|
||||
Click="OnNewSessionClicked"
|
||||
ToolTip.Tip="{loc:Tr missionControl.newSession}">
|
||||
<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 Classes="icon-btn"
|
||||
Command="{Binding ToggleLayoutCommand}"
|
||||
@@ -100,7 +111,7 @@
|
||||
BorderThickness="1,0,0,0">
|
||||
<DockPanel LastChildFill="True" Margin="10,10">
|
||||
<TextBlock DockPanel.Dock="Top" Classes="eyebrow"
|
||||
Text="{loc:Tr missionControl.queue}"
|
||||
Text="{loc:Tr missionControl.activity}"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
LetterSpacing="1.4" Margin="0,0,0,8" />
|
||||
<ScrollViewer>
|
||||
@@ -112,27 +123,31 @@
|
||||
Background="Transparent" BorderThickness="0"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
|
||||
Cursor="Hand">
|
||||
<Button.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="{loc:Tr missionControl.openSession}"
|
||||
Command="{Binding OpenSessionCommand}"
|
||||
IsEnabled="{Binding CanOpenSession}" />
|
||||
</ContextMenu>
|
||||
</Button.ContextMenu>
|
||||
<Panel>
|
||||
<Border Background="{DynamicResource SurfaceBrush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="1" CornerRadius="6" />
|
||||
<Border Background="{DynamicResource RunningTintBrush}"
|
||||
BorderBrush="{DynamicResource RunningTintBorderBrush}"
|
||||
<!-- Single state tint; brush keys come from the VM (Tokens.axaml). -->
|
||||
<Border Background="{Binding TintKey, Converter={x:Static conv:DotBrushConverter.Instance}}"
|
||||
BorderBrush="{Binding TintBorderKey, Converter={x:Static conv:DotBrushConverter.Instance}}"
|
||||
BorderThickness="1" CornerRadius="6"
|
||||
IsVisible="{Binding IsRunning}" />
|
||||
IsVisible="{Binding TintKey, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
<StackPanel Margin="8,6" 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}" />
|
||||
<TextBlock Classes="meta"
|
||||
Text="{loc:Tr missionControl.running}"
|
||||
IsVisible="{Binding IsRunning}"
|
||||
Foreground="{DynamicResource StatusRunningBrush}" />
|
||||
Text="{Binding StatusLabel}"
|
||||
IsVisible="{Binding StatusLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
Foreground="{Binding StatusColorKey, Converter={x:Static conv:DotBrushConverter.Instance}}" />
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
</Button>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user