From bbd6151ba60627417b1c84adbaf9316243a836b2 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 12 Aug 2026 09:39:42 +0200 Subject: [PATCH] feat(claude-do): [A4] WeeklyReport, DailyPrep und Planning-Aktionen auf OperationStatus umstellen WeeklyReportModalViewModel.Generate, PrepPanelViewModel.PlanDayAsync und die beiden Planning-Aktionen (QueuePlanningSubtasksAsync, FinalizePlanningSessionAsync) laufen jetzt durch je eine OperationStatus + OperationIndicator statt handgebauter Spinner. Die beiden Planning-Aktionen teilen sich eine Instanz, angezeigt im Tasks-Island-Header, weil sie aus einem sofort schliessenden Kontextmenue ausgeloest werden und es keine dauerhafte Pro-Zeilen-Flaeche gibt, an die ein Indikator gehaengt werden koennte. --- .../ViewModels/Islands/PrepPanelViewModel.cs | 29 +++- .../Islands/TasksIslandViewModel.cs | 25 +++- .../Modals/WeeklyReportModalViewModel.cs | 30 ++-- .../Views/Islands/DetailsIslandView.axaml | 12 +- .../Views/Islands/TasksIslandView.axaml | 2 + .../Views/Modals/WeeklyReportModalView.axaml | 1 + .../ViewModels/PrepPanelViewModelTests.cs | 61 ++++++++ .../TasksIslandPlanningOperationTests.cs | 133 ++++++++++++++++++ .../WeeklyReportModalViewModelTests.cs | 70 +++++++++ 9 files changed, 342 insertions(+), 21 deletions(-) create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/PrepPanelViewModelTests.cs create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandPlanningOperationTests.cs create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/WeeklyReportModalViewModelTests.cs diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/PrepPanelViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/PrepPanelViewModel.cs index 11b37ce2..2283a5de 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/PrepPanelViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/PrepPanelViewModel.cs @@ -3,6 +3,7 @@ using System.Text; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ClaudeDo.Ui.Helpers; +using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; namespace ClaudeDo.Ui.ViewModels.Islands; @@ -19,11 +20,23 @@ public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable [ObservableProperty] private bool _isPrepRunning; + // Covers the gap between the click and the worker's PrepStartedEvent broadcast — IsPrepRunning + // stays false until that event arrives, so without this the button click looks like a no-op. + public OperationStatus PrepOperation { get; } = new(); + public ObservableCollection PrepLog { get; } = new(); public bool ShowPrepEmptyState => !IsPrepRunning && PrepLog.Count == 0; - partial void OnIsPrepRunningChanged(bool value) => OnPropertyChanged(nameof(ShowPrepEmptyState)); + // The long-running broadcast-driven state (IsPrepRunning) and the click-to-event gap + // (PrepOperation.IsRunning) both need to block a second click. + public bool IsPlanDayEnabled => !IsPrepRunning && !PrepOperation.IsRunning; + + partial void OnIsPrepRunningChanged(bool value) + { + OnPropertyChanged(nameof(ShowPrepEmptyState)); + OnPropertyChanged(nameof(IsPlanDayEnabled)); + } public PrepPanelViewModel(IWorkerClient worker) { @@ -37,6 +50,15 @@ public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable _worker.PrepFinishedEvent += _onPrepFinishedHandler; PrepLog.CollectionChanged += (_, _) => OnPropertyChanged(nameof(ShowPrepEmptyState)); + + // OperationStatus is a nested ObservableObject — [NotifyCanExecuteChangedFor] can't see + // into it, so IsRunning changes are relayed by hand. + PrepOperation.PropertyChanged += (_, e) => + { + if (e.PropertyName != nameof(OperationStatus.IsRunning)) return; + PlanDayCommand.NotifyCanExecuteChanged(); + OnPropertyChanged(nameof(IsPlanDayEnabled)); + }; } public void Dispose() @@ -46,9 +68,12 @@ public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable _worker.PrepFinishedEvent -= _onPrepFinishedHandler; } - [RelayCommand] + private bool CanPlanDay() => IsPlanDayEnabled; + + [RelayCommand(CanExecute = nameof(CanPlanDay))] private async System.Threading.Tasks.Task PlanDayAsync() { + using var op = PrepOperation.Begin(Loc.T("ops.reports.runningDailyPrep")); try { await _worker.RunDailyPrepNowAsync(); } catch { } } diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index 1e52b062..7df5142a 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -101,6 +101,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable [ObservableProperty] private bool _isLetClaudeVisible; [ObservableProperty] private bool _isQuickClaudeVisible; + // Shared by QueuePlanningSubtasksAsync and FinalizePlanningSessionAsync — both are triggered + // from a context menu that closes the instant a click lands, so there is no per-row surface + // left standing to anchor an indicator to. The island header is the nearest surface still + // visible after the menu closes. + public OperationStatus PlanningOperation { get; } = new(); + public event EventHandler? LetClaudeHandleRequested; [RelayCommand] @@ -152,6 +158,15 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable Loc.LanguageChanged += _langChangedHandler; _reconcileTimer.Elapsed += (_, _) => Dispatcher.UIThread.Post(() => _ = ReconcileTickAsync()); _reconcileTimer.Start(); + + // OperationStatus is a nested ObservableObject — [NotifyCanExecuteChangedFor] can't see + // into it, so IsRunning changes are relayed by hand. + PlanningOperation.PropertyChanged += (_, e) => + { + if (e.PropertyName != nameof(OperationStatus.IsRunning)) return; + QueuePlanningSubtasksCommand.NotifyCanExecuteChanged(); + FinalizePlanningSessionCommand.NotifyCanExecuteChanged(); + }; } public void Dispose() @@ -1337,7 +1352,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable OpenPlanningConPtyRequested?.Invoke(row.Id, true); break; case UnfinishedPlanningModalResult.FinalizeNow: - await _worker.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false); + await FinalizePlanningSessionAsync(row); break; case UnfinishedPlanningModalResult.Discard: await TryDiscardPlanningWithRetryAsync(row.Id); @@ -1393,18 +1408,22 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable /// public Func>? ConfirmAsync { get; set; } - [RelayCommand] + private bool CanRunPlanningOperation() => !PlanningOperation.IsRunning; + + [RelayCommand(CanExecute = nameof(CanRunPlanningOperation))] private async Task QueuePlanningSubtasksAsync(TaskRowViewModel? row) { if (row is null || _worker is null) return; + using var op = PlanningOperation.Begin(Loc.T("ops.planning.queuingSubtasks")); try { await _worker.QueuePlanningSubtasksAsync(row.Id); } catch { } } - [RelayCommand] + [RelayCommand(CanExecute = nameof(CanRunPlanningOperation))] private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row) { if (row is null) return; + using var op = PlanningOperation.Begin(Loc.T("ops.planning.finalizing")); try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false); } catch { } } diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/WeeklyReportModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/WeeklyReportModalViewModel.cs index 35f63791..e4249b34 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/WeeklyReportModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/WeeklyReportModalViewModel.cs @@ -9,24 +9,32 @@ public sealed partial class WeeklyReportModalViewModel : ViewModelBase { private readonly IWorkerClient _worker; - public WeeklyReportModalViewModel(IWorkerClient worker) => _worker = worker; + public WeeklyReportModalViewModel(IWorkerClient worker) + { + _worker = worker; + // OperationStatus is a nested ObservableObject — [NotifyCanExecuteChangedFor] can't see + // into it, so IsRunning changes are relayed by hand. + GenerateOperation.PropertyChanged += (_, e) => + { + if (e.PropertyName != nameof(OperationStatus.IsRunning)) return; + GenerateCommand.NotifyCanExecuteChanged(); + OnPropertyChanged(nameof(EmptyStateVisible)); + }; + } + + public OperationStatus GenerateOperation { get; } = new(); [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasReport))] [NotifyPropertyChangedFor(nameof(EmptyStateVisible))] private string? _reportMarkdown; - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(EmptyStateVisible))] - [NotifyCanExecuteChangedFor(nameof(GenerateCommand))] - private bool _isBusy; - [ObservableProperty] private DateTime? _startDate; [ObservableProperty] private DateTime? _endDate; [ObservableProperty] private string _statusMessage = ""; public bool HasReport => !string.IsNullOrWhiteSpace(ReportMarkdown); - public bool EmptyStateVisible => !HasReport && !IsBusy; + public bool EmptyStateVisible => !HasReport && !GenerateOperation.IsRunning; public Action? CloseAction { get; set; } [RelayCommand] private void Close() => CloseAction?.Invoke(); @@ -68,21 +76,19 @@ public sealed partial class WeeklyReportModalViewModel : ViewModelBase catch (Exception ex) { StatusMessage = ex.Message; } } - private bool CanGenerate() => !IsBusy; + private bool CanGenerate() => !GenerateOperation.IsRunning; [RelayCommand(CanExecute = nameof(CanGenerate))] private async Task Generate() { if (!RangeValid) { StatusMessage = Loc.T("vm.weeklyReport.invalidRange"); return; } - IsBusy = true; - StatusMessage = Loc.T("vm.weeklyReport.generating"); + using var op = GenerateOperation.Begin(Loc.T("ops.reports.generatingWeekly")); + StatusMessage = ""; try { ReportMarkdown = await _worker.GenerateWeekReportAsync( DateOnly.FromDateTime(StartDate!.Value), DateOnly.FromDateTime(EndDate!.Value)); - StatusMessage = ""; } catch (Exception ex) { StatusMessage = Loc.T("vm.weeklyReport.error", ex.Message); } - finally { IsBusy = false; } } } diff --git a/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml index 7528c50a..7d20b634 100644 --- a/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml @@ -3,6 +3,7 @@ xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands" xmlns:islands="using:ClaudeDo.Ui.Views.Islands" xmlns:detail="using:ClaudeDo.Ui.Views.Islands.Detail" + xmlns:ctl="using:ClaudeDo.Ui.Views.Controls" xmlns:loc="using:ClaudeDo.Ui.Localization" x:Class="ClaudeDo.Ui.Views.Islands.DetailsIslandView" x:DataType="vm:DetailsIslandViewModel" @@ -154,10 +155,13 @@ -