fix(ui): unfreeze the operation spinner and attribute rebind churn

Three causes behind the stuck indicator:
- OperationStatus.End did not retire the generation, so tick work posted
  while the dispatcher was blocked drained afterwards and set
  ShowIndicator back to true on a finished operation.
- The startup update check ran in Task.Run; OperationStatus writes its
  observable properties on the calling thread, so the final
  ShowIndicator=false never reached the binding.
- OperationIndicator overwrote its own DataContext from the Status
  property, which re-targeted the call-site binding. It now scopes the
  DataContext to an inner panel and collapses itself when Status is
  null; every call site switched from DataContext= to Status=.

Also gates the footer connection pill in the command body instead of
CanExecute (a disabled command greyed out the ONLINE chip), and extends
OperationTiming: pid per line, 4 MB rollover, fast successes dropped
(failures always kept), and DetailsIsland.BindAsync now carries the
selection trigger via TasksIslandViewModel.SelectFrom.
This commit is contained in:
mika kuns
2026-08-12 13:50:05 +02:00
parent b9827eac01
commit 589b9e75f3
19 changed files with 198 additions and 38 deletions
@@ -7,6 +7,11 @@ namespace ClaudeDo.Ui.Services;
/// 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.
///
/// **Call Begin/Report/Dispose from the UI thread.** Only the timer tick is marshalled; the other
/// three write their observable properties inline on the calling thread, so an off-thread caller
/// leaves the bindings updating off the UI thread. A [RelayCommand] satisfies this by itself (its
/// awaits resume on the UI thread); wrapping the call in Task.Run breaks it.
public sealed partial class OperationStatus : ObservableObject
{
private static readonly TimeSpan GracePeriod = TimeSpan.FromMilliseconds(300);
@@ -79,6 +84,12 @@ public sealed partial class OperationStatus : ObservableObject
private void End(int generation)
{
if (generation != _generation) return;
// Retire the generation *before* touching the flags. The timer callback fires on a
// threadpool thread and only posts its UI work, so a tick that fired while the dispatcher
// was busy can drain *after* End — with the generation still current it would set
// ShowIndicator back to true on an already-finished operation, and since the timer is gone
// by then no later tick ever clears it. That is the frozen spinner.
_generation++;
_tickTimer?.Dispose();
_tickTimer = null;
IsRunning = false;