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
@@ -47,6 +47,19 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
private readonly System.Timers.Timer _reconcileTimer = new(4_000);
public event EventHandler? SelectionChanged;
/// <summary>Why the selection last changed. Recorded with the detail-pane bind, so the timing
/// log attributes rebind churn to a trigger instead of leaving an anonymous count.</summary>
public string SelectionSource { get; private set; } = "init";
/// <summary>Sets <see cref="SelectedTask"/> and tags what triggered it — use this instead of
/// assigning the property, otherwise the bind is logged as "?" .</summary>
public void SelectFrom(TaskRowViewModel? row, string source)
{
SelectionSource = source;
SelectedTask = row;
}
public event EventHandler? FocusAddTaskRequested;
public event EventHandler? TasksChanged;
public event Action? NotesRequested;
@@ -57,7 +70,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
[RelayCommand]
private void OpenNotes()
{
SelectedTask = null;
SelectFrom(null, "notes");
NotesRequested?.Invoke();
}
@@ -359,7 +372,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
HasCompleted = false;
ShowOpenLabel = false;
ShowNotesRow = false;
if (listChanged) SelectedTask = null;
if (listChanged) SelectFrom(null, "list-change");
if (list is null) { IsLetClaudeVisible = false; IsQuickClaudeVisible = false; LoadTask = Task.CompletedTask; return; }
HeaderTitle = list.Name;
@@ -772,7 +785,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
row.ShowListChip = _currentList?.Kind == ListKind.Virtual;
Items.Add(row);
Regroup();
SelectedTask = row;
SelectFrom(row, "new-task");
NewTaskTitle = "";
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
@@ -1272,7 +1285,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
row is null ? Task.CompletedTask : SetScheduledForAsync(row, null);
[RelayCommand]
private void Select(TaskRowViewModel row) => SelectedTask = row;
private void Select(TaskRowViewModel row) => SelectFrom(row, "row-click");
public async System.Threading.Tasks.Task<bool> SelectByIdAsync(string taskId)
{
@@ -1282,7 +1295,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
}
var row = Items.FirstOrDefault(r => r.Id == taskId);
if (row is null) return false;
SelectedTask = row;
SelectFrom(row, "select-by-id");
return true;
}