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:
Mika Kuns
2026-08-12 08:31:38 +02:00
parent eca16a3506
commit ca65849280
6 changed files with 445 additions and 0 deletions
+50
View File
@@ -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" }, "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}" } "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": { "usage": {
"pill": { "pill": {
"empty": "Nutzung ", "empty": "Nutzung ",
+50
View File
@@ -692,6 +692,56 @@
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" }, "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}" } "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": { "usage": {
"pill": { "pill": {
"empty": "Usage ", "empty": "Usage ",
+110
View File
@@ -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);
}
}