diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index fa49eb11..8901dbed 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -692,6 +692,56 @@ "lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" }, "repoImport": { "loadFailed": "Gespeicherte Ordner konnten nicht geladen werden: {0}", "saveFailed": "Ordner konnten nicht gespeichert werden: {0}" } }, + "ops": { + "stalledHint": "Läuft weiter — Worker antwortet noch", + "merge": { + "merging": "Wird zusammengeführt…", + "verifying": "Verify-Kommando der Liste läuft… ({0})" + }, + "review": { + "submitting": "Wird zur Prüfung eingereicht…", + "rejecting": "Wird abgelehnt…", + "parking": "Wird geparkt…", + "previewing": "Vorschau wird geladen…" + }, + "worktrees": { + "refreshing": "Wird aktualisiert…", + "cleaningUp": "Abgeschlossene Worktrees werden aufgeräumt…", + "resetting": "Worktree wird zurückgesetzt…", + "forceRemoving": "Worktree wird zwangsweise entfernt…", + "batchMerging": "Merge {0}/{1}…" + }, + "skills": { + "installing": "Skill wird geklont…", + "updating": "Skill wird aktualisiert…", + "restoringDefaults": "Standard-Agenten werden wiederhergestellt…" + }, + "onlineInbox": { + "signingIn": "Wird angemeldet…" + }, + "repoImport": { + "scanning": "Repositories werden gescannt…" + }, + "updateCheck": { + "checking": "Wird nach Updates gesucht…" + }, + "reports": { + "generatingWeekly": "Bericht wird erstellt…", + "runningDailyPrep": "Tagesvorbereitung läuft…" + }, + "planning": { + "buildingIntegrationBranch": "Integrations-Branch wird erstellt…", + "loadingAggregate": "Kombinierte Vorschau wird geladen…", + "finalizing": "Plan wird finalisiert…", + "queuingSubtasks": "Teilaufgaben werden eingereiht…" + }, + "worker": { + "startupRecovery": "Wiederherstellung läuft…", + "creatingWorktree": "Worktree wird erstellt…", + "rebasingAfterMerge": "Andere Worktrees werden rebased…", + "maintainingWorktrees": "Worktree-Wartung läuft…" + } + }, "usage": { "pill": { "empty": "Nutzung –", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index ad9cdd65..1a7d6493 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -692,6 +692,56 @@ "lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" }, "repoImport": { "loadFailed": "Couldn't load remembered folders: {0}", "saveFailed": "Couldn't save folders: {0}" } }, + "ops": { + "stalledHint": "Still running — worker hasn't responded in a while", + "merge": { + "merging": "Merging…", + "verifying": "Running the list's verify command… ({0})" + }, + "review": { + "submitting": "Submitting for review…", + "rejecting": "Rejecting…", + "parking": "Parking…", + "previewing": "Loading preview…" + }, + "worktrees": { + "refreshing": "Refreshing…", + "cleaningUp": "Cleaning up finished worktrees…", + "resetting": "Resetting worktree…", + "forceRemoving": "Force-removing worktree…", + "batchMerging": "Merging {0}/{1}…" + }, + "skills": { + "installing": "Cloning skill…", + "updating": "Updating skill…", + "restoringDefaults": "Restoring default agents…" + }, + "onlineInbox": { + "signingIn": "Signing in…" + }, + "repoImport": { + "scanning": "Scanning repositories…" + }, + "updateCheck": { + "checking": "Checking for updates…" + }, + "reports": { + "generatingWeekly": "Generating report…", + "runningDailyPrep": "Running daily prep…" + }, + "planning": { + "buildingIntegrationBranch": "Building integration branch…", + "loadingAggregate": "Loading combined preview…", + "finalizing": "Finalizing plan…", + "queuingSubtasks": "Queuing subtasks…" + }, + "worker": { + "startupRecovery": "Recovering…", + "creatingWorktree": "Creating worktree…", + "rebasingAfterMerge": "Rebasing other worktrees…", + "maintainingWorktrees": "Maintaining worktrees…" + } + }, "usage": { "pill": { "empty": "Usage –", diff --git a/src/ClaudeDo.Ui/Services/OperationStatus.cs b/src/ClaudeDo.Ui/Services/OperationStatus.cs new file mode 100644 index 00000000..bcf04e1a --- /dev/null +++ b/src/ClaudeDo.Ui/Services/OperationStatus.cs @@ -0,0 +1,110 @@ +using System.Threading; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace ClaudeDo.Ui.Services; + +/// Feedback primitive for a single long-running operation behind a [RelayCommand]. Multiple +/// independent instances per ViewModel are expected (one per command) — there is no shared or +/// static state, which is exactly what a static DispatcherTimer would introduce and the cause of +/// the order-dependent flakiness in Ui.Tests. +public sealed partial class OperationStatus : ObservableObject +{ + private static readonly TimeSpan GracePeriod = TimeSpan.FromMilliseconds(300); + private static readonly TimeSpan StallThreshold = TimeSpan.FromSeconds(60); + private static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(1); + + private readonly TimeProvider _time; + private readonly Action _postToUiThread; + + private ITimer? _tickTimer; + private DateTimeOffset _startedAt; + private DateTimeOffset _lastReportAt; + private int _generation; + + [ObservableProperty] private bool _isRunning; + [ObservableProperty] private bool _showIndicator; + [ObservableProperty] private string? _label; + [ObservableProperty] private string _elapsed = "00:00"; + [ObservableProperty] private bool _isStalled; + + public OperationStatus(TimeProvider? timeProvider = null) : this(timeProvider, null) { } + + /// Test-only seam. Production always marshals the threadpool timer callback onto the + /// dispatcher; tests inject a synchronous poster so they can drive a fake clock without + /// bootstrapping a real Avalonia dispatcher. + internal OperationStatus(TimeProvider? timeProvider, Action? postToUiThread) + { + _time = timeProvider ?? TimeProvider.System; + _postToUiThread = postToUiThread ?? (action => Avalonia.Threading.Dispatcher.UIThread.Post(action)); + } + + /// Starts the operation; Dispose (even via an exception unwinding a `using` block) ends it. + public IDisposable Begin(string label) + { + _tickTimer?.Dispose(); + var generation = ++_generation; + + _startedAt = _time.GetUtcNow(); + _lastReportAt = _startedAt; + Label = label; + Elapsed = FormatElapsed(TimeSpan.Zero); + ShowIndicator = false; + IsStalled = false; + IsRunning = true; + + _tickTimer = _time.CreateTimer(_ => OnTick(generation), null, GracePeriod, TickInterval); + + return new EndOnDispose(this, generation); + } + + /// Overwrites the label mid-flight and resets the stall clock — call this whenever the + /// operation's phase changes so a normally multi-minute step doesn't report itself as stuck. + public void Report(string label) + { + Label = label; + _lastReportAt = _time.GetUtcNow(); + IsStalled = false; + } + + private void OnTick(int generation) => _postToUiThread(() => + { + if (generation != _generation) return; + + var now = _time.GetUtcNow(); + Elapsed = FormatElapsed(now - _startedAt); + if (!ShowIndicator && now - _startedAt >= GracePeriod) ShowIndicator = true; + IsStalled = now - _lastReportAt >= StallThreshold; + }); + + private void End(int generation) + { + if (generation != _generation) return; + _tickTimer?.Dispose(); + _tickTimer = null; + IsRunning = false; + ShowIndicator = false; + IsStalled = false; + } + + private static string FormatElapsed(TimeSpan elapsed) => + (elapsed < TimeSpan.Zero ? TimeSpan.Zero : elapsed).ToString(@"mm\:ss"); + + private sealed class EndOnDispose : IDisposable + { + private OperationStatus? _owner; + private readonly int _generation; + + public EndOnDispose(OperationStatus owner, int generation) + { + _owner = owner; + _generation = generation; + } + + public void Dispose() + { + var owner = _owner; + _owner = null; + owner?.End(_generation); + } + } +} diff --git a/src/ClaudeDo.Ui/Views/Controls/OperationIndicator.axaml b/src/ClaudeDo.Ui/Views/Controls/OperationIndicator.axaml new file mode 100644 index 00000000..c05b9b90 --- /dev/null +++ b/src/ClaudeDo.Ui/Views/Controls/OperationIndicator.axaml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/src/ClaudeDo.Ui/Views/Controls/OperationIndicator.axaml.cs b/src/ClaudeDo.Ui/Views/Controls/OperationIndicator.axaml.cs new file mode 100644 index 00000000..93ace2c7 --- /dev/null +++ b/src/ClaudeDo.Ui/Views/Controls/OperationIndicator.axaml.cs @@ -0,0 +1,26 @@ +using Avalonia; +using Avalonia.Controls; +using ClaudeDo.Ui.Services; + +namespace ClaudeDo.Ui.Views.Controls; + +public partial class OperationIndicator : UserControl +{ + public static readonly StyledProperty StatusProperty = + AvaloniaProperty.Register(nameof(Status)); + + public OperationStatus? Status + { + get => GetValue(StatusProperty); + set => SetValue(StatusProperty, value); + } + + public OperationIndicator() => InitializeComponent(); + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == StatusProperty) + DataContext = change.GetNewValue(); + } +} diff --git a/tests/ClaudeDo.Ui.Tests/Services/OperationStatusTests.cs b/tests/ClaudeDo.Ui.Tests/Services/OperationStatusTests.cs new file mode 100644 index 00000000..53f38145 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/Services/OperationStatusTests.cs @@ -0,0 +1,193 @@ +using System.Threading; +using ClaudeDo.Ui.Services; + +namespace ClaudeDo.Ui.Tests.Services; + +public class OperationStatusTests +{ + // Minimal hand-rolled fake: TimeProvider's default CreateTimer schedules against the real + // wall clock regardless of a GetUtcNow() override, so a usable fake must implement its own + // timer queue and fire callbacks synchronously from Advance(). + private sealed class FakeTimeProvider : TimeProvider + { + private readonly List _timers = new(); + private DateTimeOffset _utcNow; + + public FakeTimeProvider(DateTimeOffset start) => _utcNow = start; + + public override DateTimeOffset GetUtcNow() => _utcNow; + + public void Advance(TimeSpan by) + { + var target = _utcNow + by; + while (true) + { + FakeTimer? next = null; + foreach (var timer in _timers) + { + if (timer.NextDue is not { } due || due > target) continue; + if (next is null || due < next.NextDue) next = timer; + } + if (next is null) break; + _utcNow = next.NextDue!.Value; + next.Fire(); + } + _utcNow = target; + } + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + var timer = new FakeTimer(this, callback, state, dueTime, period); + _timers.Add(timer); + return timer; + } + + private sealed class FakeTimer : ITimer + { + private readonly FakeTimeProvider _owner; + private readonly TimerCallback _callback; + private readonly object? _state; + private TimeSpan _period; + + public DateTimeOffset? NextDue { get; private set; } + + public FakeTimer(FakeTimeProvider owner, TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + _owner = owner; + _callback = callback; + _state = state; + _period = period; + NextDue = dueTime == Timeout.InfiniteTimeSpan ? null : owner._utcNow + dueTime; + } + + public void Fire() + { + _callback(_state); + NextDue = _period <= TimeSpan.Zero || _period == Timeout.InfiniteTimeSpan + ? null + : NextDue + _period; + } + + public bool Change(TimeSpan dueTime, TimeSpan period) + { + _period = period; + NextDue = dueTime == Timeout.InfiniteTimeSpan ? null : _owner._utcNow + dueTime; + return true; + } + + public void Dispose() => _owner._timers.Remove(this); + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + } + } + + // Production posts the threadpool timer callback via Dispatcher.UIThread.Post; tests run + // without a bootstrapped Avalonia dispatcher, so the callback is invoked synchronously instead. + private static OperationStatus Create(FakeTimeProvider time) => new(time, action => action()); + + [Fact] + public void Begin_sets_IsRunning_immediately_and_ShowIndicator_after_grace_period() + { + var time = new FakeTimeProvider(DateTimeOffset.UtcNow); + var status = Create(time); + + using var op = status.Begin("Working…"); + Assert.True(status.IsRunning); + Assert.False(status.ShowIndicator); + + time.Advance(TimeSpan.FromMilliseconds(300)); + Assert.True(status.ShowIndicator); + } + + [Fact] + public void ShowIndicator_stays_false_when_the_operation_ends_before_grace_period() + { + var time = new FakeTimeProvider(DateTimeOffset.UtcNow); + var status = Create(time); + + var op = status.Begin("Working…"); + time.Advance(TimeSpan.FromMilliseconds(200)); + op.Dispose(); + time.Advance(TimeSpan.FromMilliseconds(200)); // past the original 300ms grace mark + + Assert.False(status.ShowIndicator); + Assert.False(status.IsRunning); + } + + [Fact] + public void IsStalled_measures_from_the_last_Report_not_from_Begin() + { + var time = new FakeTimeProvider(DateTimeOffset.UtcNow); + var status = Create(time); + + using var op = status.Begin("Working…"); + time.Advance(TimeSpan.FromSeconds(50)); + Assert.False(status.IsStalled); + + status.Report("Still working…"); + time.Advance(TimeSpan.FromSeconds(59)); // 109s total, only 59s since the Report + Assert.False(status.IsStalled); + + time.Advance(TimeSpan.FromSeconds(2)); // 111s total, 61s since the Report + Assert.True(status.IsStalled); + } + + [Fact] + public void No_report_at_all_stalls_60_seconds_after_Begin() + { + var time = new FakeTimeProvider(DateTimeOffset.UtcNow); + var status = Create(time); + + using var op = status.Begin("Working…"); + time.Advance(TimeSpan.FromSeconds(59)); + Assert.False(status.IsStalled); + + time.Advance(TimeSpan.FromSeconds(2)); + Assert.True(status.IsStalled); + } + + [Fact] + public void Elapsed_is_formatted_as_mm_ss() + { + var time = new FakeTimeProvider(DateTimeOffset.UtcNow); + var status = Create(time); + + using var op = status.Begin("Working…"); + time.Advance(TimeSpan.FromMilliseconds(74_300)); // lands exactly on a tick + + Assert.Equal("01:14", status.Elapsed); + } + + [Fact] + public void Dispose_ends_the_operation_even_when_the_using_block_throws() + { + var time = new FakeTimeProvider(DateTimeOffset.UtcNow); + var status = Create(time); + + Assert.Throws((Action)(() => + { + using var op = status.Begin("Working…"); + time.Advance(TimeSpan.FromMilliseconds(300)); + throw new InvalidOperationException("boom"); + })); + + Assert.False(status.IsRunning); + Assert.False(status.ShowIndicator); + } + + [Fact] + public void Report_overwrites_the_label() + { + var time = new FakeTimeProvider(DateTimeOffset.UtcNow); + var status = Create(time); + + using var op = status.Begin("Merging…"); + status.Report("Verifying…"); + + Assert.Equal("Verifying…", status.Label); + } +}