diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index 198bd032..2d86ce68 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -1298,6 +1298,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable if (ReconcileTickTestBarrier is { } barrier) await barrier(); var byId = entities.ToDictionary(e => e.Id); + // Index the rows once instead of scanning Items per id — at the 500-row cap a linear + // scan per id is 250k comparisons every few seconds, for nothing. + var rowById = new Dictionary(Items.Count); + foreach (var r in Items) rowById[r.Id] = r; foreach (var id in ids) { // Superseded by a fresher delta refresh or a later tick that landed while this one @@ -1305,8 +1309,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable if (!_deltaSeq.TryGetValue(id, out var current) || current != seqByTaskId[id]) continue; if (!byId.TryGetValue(id, out var entity)) continue; // deleted; the delta path removes rows, not the tick - var row = Items.FirstOrDefault(r => r.Id == id); - row?.UpdateFromEntity(entity); + if (rowById.TryGetValue(id, out var row)) row.UpdateFromEntity(entity); } } } diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/LogVisualizerViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/LogVisualizerViewModel.cs index 9b4a8a27..b775b323 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/LogVisualizerViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/LogVisualizerViewModel.cs @@ -75,10 +75,18 @@ public sealed partial class LogVisualizerViewModel : ViewModelBase [RelayCommand] private void Close() + { + StopReconcileTick(); + CloseAction?.Invoke(); + } + + /// Stops the reconcile tick. Also called from the dialog's native-close fallback + /// (WindowDialogService), which bypasses — otherwise an Alt+F4 + /// leaves the timer polling the worker on an orphaned VM. Idempotent. + internal void StopReconcileTick() { _reconcileTimer.Stop(); _reconcileTimer.Dispose(); - CloseAction?.Invoke(); } private bool CanCopyLast() => Rows.Count > 0; diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs index 8bce9961..1c839acb 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs @@ -192,10 +192,18 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase [RelayCommand] private void Close() + { + StopReconcileTick(); + CloseAction?.Invoke(); + } + + /// Stops the reconcile tick. Also called from the dialog's native-close fallback + /// (WindowDialogService), which bypasses — otherwise an Alt+F4 + /// leaves the timer re-running LoadAsync (git + DB) on an orphaned VM. Idempotent. + internal void StopReconcileTick() { _reconcileTimer.Stop(); _reconcileTimer.Dispose(); - CloseAction?.Invoke(); } [RelayCommand] diff --git a/src/ClaudeDo.Ui/Views/WindowDialogService.cs b/src/ClaudeDo.Ui/Views/WindowDialogService.cs index 9ccad7e3..812b5e54 100644 --- a/src/ClaudeDo.Ui/Views/WindowDialogService.cs +++ b/src/ClaudeDo.Ui/Views/WindowDialogService.cs @@ -107,6 +107,7 @@ public sealed class WindowDialogService : IDialogService { var dlg = new WorktreesOverviewModalView { DataContext = vm }; vm.CloseAction = () => dlg.Close(); + dlg.Closed += (_, _) => vm.StopReconcileTick(); // native close bypasses CloseCommand vm.JumpToTaskAction = (listId, taskId) => { if (Shell is { } s) _ = JumpToTaskHelper.SelectAsync(s, listId, taskId); @@ -161,6 +162,7 @@ public sealed class WindowDialogService : IDialogService { var dlg = new LogVisualizerView { DataContext = vm }; vm.CloseAction = () => dlg.Close(); + dlg.Closed += (_, _) => vm.StopReconcileTick(); // native close bypasses CloseCommand await dlg.ShowDialog(_owner); }