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:
@@ -578,7 +578,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
public void ShowNotes()
|
||||
{
|
||||
Bind(null);
|
||||
Bind(null, "notes");
|
||||
IsPrepMode = false;
|
||||
IsNotesMode = true;
|
||||
_ = Notes.LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
|
||||
@@ -586,13 +586,15 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
public void ShowPrep()
|
||||
{
|
||||
Bind(null);
|
||||
Bind(null, "prep");
|
||||
IsNotesMode = false;
|
||||
IsPrepMode = true;
|
||||
_ = Prep.LoadLastPrepLogIfEmptyAsync();
|
||||
}
|
||||
|
||||
public void Bind(TaskRowViewModel? row)
|
||||
/// <param name="source">What triggered the selection (<see cref="TasksIslandViewModel.SelectionSource"/>);
|
||||
/// logged with the bind timing so rebind churn can be attributed to a trigger.</param>
|
||||
public void Bind(TaskRowViewModel? row, string source = "?")
|
||||
{
|
||||
IsNotesMode = false;
|
||||
IsPrepMode = false;
|
||||
@@ -634,10 +636,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
// agent output, so the Output tab would open empty -- its work shows up as git changes.
|
||||
SelectedTab = row.IsManual || row.HasInteractiveSession ? "git" : "output";
|
||||
|
||||
_ = BindAsync(row, ct);
|
||||
_ = BindAsync(row, ct, source);
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task BindAsync(TaskRowViewModel row, CancellationToken ct)
|
||||
private async System.Threading.Tasks.Task BindAsync(TaskRowViewModel row, CancellationToken ct, string source)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var ok = false;
|
||||
@@ -735,7 +737,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
ok = true;
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
finally { OperationTiming.Shared.Record("db", "DetailsIsland.BindAsync", sw.Elapsed, ok); }
|
||||
finally { OperationTiming.Shared.Record("db", $"DetailsIsland.BindAsync:{source}", sw.Elapsed, ok); }
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task LoadChildOutcomesAsync(string parentTaskId, CancellationToken ct)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user