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:
@@ -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;
|
||||
|
||||
@@ -10,26 +10,49 @@ namespace ClaudeDo.Ui.Services;
|
||||
/// </summary>
|
||||
public sealed class OperationTiming
|
||||
{
|
||||
/// <summary>Fast successful operations are dropped: the first day of data was 43% 3ms reconcile
|
||||
/// ticks, which crowd out the outliers the file exists to surface. Failed/cancelled operations
|
||||
/// are always kept regardless of duration — a 1ms cancelled call is a churn signal, not noise.</summary>
|
||||
public const int DefaultMinMs = 25;
|
||||
|
||||
/// <summary>One rollover file is kept (<c>.1</c>); at ~1000 lines/h a 4 MB cap holds weeks.</summary>
|
||||
public const long DefaultMaxBytes = 4L * 1024 * 1024;
|
||||
|
||||
public static string DefaultPath =>
|
||||
Path.Combine(Paths.AppDataRoot(), "logs", "operation-timing.ndjson");
|
||||
|
||||
public static OperationTiming Shared { get; } = new(DefaultPath);
|
||||
|
||||
private static readonly int ProcessId = Environment.ProcessId;
|
||||
|
||||
private readonly string _path;
|
||||
private readonly int _minMs;
|
||||
private readonly long _maxBytes;
|
||||
private readonly object _writeLock = new();
|
||||
|
||||
public OperationTiming(string filePath) => _path = filePath;
|
||||
public OperationTiming(string filePath, int minMs = DefaultMinMs, long maxBytes = DefaultMaxBytes)
|
||||
{
|
||||
_path = filePath;
|
||||
_minMs = minMs;
|
||||
_maxBytes = maxBytes;
|
||||
}
|
||||
|
||||
public void Record(string kind, string operation, TimeSpan elapsed, bool ok)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ms = (long)elapsed.TotalMilliseconds;
|
||||
if (ok && ms < _minMs) return;
|
||||
|
||||
var line = JsonSerializer.Serialize(new
|
||||
{
|
||||
ts = DateTime.UtcNow,
|
||||
// Runs of the app interleave in one file; without the pid, a restart looks like
|
||||
// concurrent work by a single instance.
|
||||
pid = ProcessId,
|
||||
kind,
|
||||
op = operation,
|
||||
ms = (long)elapsed.TotalMilliseconds,
|
||||
ms,
|
||||
ok,
|
||||
});
|
||||
|
||||
@@ -38,6 +61,7 @@ public sealed class OperationTiming
|
||||
var dir = Path.GetDirectoryName(_path);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
Roll();
|
||||
File.AppendAllText(_path, line + Environment.NewLine);
|
||||
}
|
||||
}
|
||||
@@ -46,4 +70,12 @@ public sealed class OperationTiming
|
||||
// A measurement must never disturb the app.
|
||||
}
|
||||
}
|
||||
|
||||
private void Roll()
|
||||
{
|
||||
if (!File.Exists(_path) || new FileInfo(_path).Length < _maxBytes) return;
|
||||
var rolled = _path + ".1";
|
||||
File.Delete(rolled);
|
||||
File.Move(_path, rolled);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user