feat(ui): add OperationStatus feedback primitive and OperationIndicator control
Foundation for long-running-operation feedback (P0-1). OperationStatus tracks IsRunning/ShowIndicator(300ms grace)/Elapsed/IsStalled(60s since last Report) via an injectable TimeProvider, no static timer. OperationIndicator binds a status to the shared spinner style. Pre-provisions ops.* locale keys for groups A and C; no ViewModel wired up yet.
This commit is contained in:
@@ -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 –",
|
||||
|
||||
@@ -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 –",
|
||||
|
||||
@@ -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<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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:svc="using:ClaudeDo.Ui.Services"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
x:Class="ClaudeDo.Ui.Views.Controls.OperationIndicator"
|
||||
x:DataType="svc:OperationStatus">
|
||||
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}" VerticalAlignment="Center"
|
||||
IsVisible="{Binding ShowIndicator}">
|
||||
<Ellipse Classes="spinner" VerticalAlignment="Center"/>
|
||||
<TextBlock Classes="meta" VerticalAlignment="Center" Text="{Binding Label}"/>
|
||||
<TextBlock Classes="meta" VerticalAlignment="Center" Text="{Binding Elapsed}"/>
|
||||
<TextBlock Classes="meta" VerticalAlignment="Center" Opacity="0.7"
|
||||
Text="{loc:Tr ops.stalledHint}"
|
||||
IsVisible="{Binding IsStalled}"/>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -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<OperationStatus?> StatusProperty =
|
||||
AvaloniaProperty.Register<OperationIndicator, OperationStatus?>(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<OperationStatus?>();
|
||||
}
|
||||
}
|
||||
@@ -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<FakeTimer> _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<InvalidOperationException>((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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user