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
@@ -303,7 +303,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
_git = git;
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
Lists.SelectionChanged += (_, _) => _ = RefreshStaleWorkerCheckAsync();
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask, Tasks.SelectionSource);
Tasks.NotesRequested += () => Details.ShowNotes();
Tasks.PrepRequested += () => Details.ShowPrep();
Tasks.ErrorReported += FlashFooterError;
@@ -340,7 +340,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
Lists.LetClaudeHandleListCommand.Execute(row);
};
Details.ErrorReported += FlashFooterError;
Details.CloseDetail = () => Tasks.SelectedTask = null;
Details.CloseDetail = () => Tasks.SelectFrom(null, "close-detail");
Details.DeleteFromList = row =>
{
Tasks.LoadForList(Lists.SelectedList);
@@ -354,7 +354,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
OnPropertyChanged(nameof(ConnectionText));
OnPropertyChanged(nameof(IsOffline));
OnPropertyChanged(nameof(CanOpenWorkerConnectionHelp));
OpenWorkerConnectionHelpCommand.NotifyCanExecuteChanged();
}
};
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
@@ -390,11 +389,16 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
{
if (e.PropertyName == nameof(OperationStatus.IsRunning)) CheckForUpdatesCommand.NotifyCanExecuteChanged();
};
// Fire-and-forget startup check — never block UI.
_ = Task.Run(async () =>
{
try { await _updateCheck.CheckNowAsync(CancellationToken.None); } catch { }
});
// Fire-and-forget startup check — never block UI. Deliberately *not* Task.Run: the check is
// pure async I/O (no sync blocking), and `OperationStatus` writes its observable properties
// on the calling thread. From a threadpool thread the final `ShowIndicator=false` never
// reaches the binding, so the indicator froze on the last tick ("Checking… 00:03").
_ = StartupUpdateCheckAsync();
}
private async Task StartupUpdateCheckAsync()
{
try { await _updateCheck.CheckNowAsync(CancellationToken.None); } catch { }
}
public void Dispose()
@@ -526,8 +530,12 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
if (Dialogs is not null) await Dialogs.ShowWorkerConnectionAsync(vm);
}
[RelayCommand(CanExecute = nameof(CanOpenWorkerConnectionHelp))]
private Task OpenWorkerConnectionHelp() => OpenWorkerConnectionHelpAsync();
// The gate lives in the body, not in CanExecute: the footer pill *is* this button, so a
// disabled command renders the "ONLINE" chip greyed out — which reads as a broken connection,
// the opposite of what it says.
[RelayCommand]
private Task OpenWorkerConnectionHelp() =>
CanOpenWorkerConnectionHelp ? OpenWorkerConnectionHelpAsync() : Task.CompletedTask;
[RelayCommand]
private async Task OpenRepoImport()