fix(ui): dispose VM subscriptions/timers, guard offline Stop, align review delta-path

- DetailsIslandViewModel/TasksIslandViewModel/ListsIslandViewModel: implement
  IDisposable, unsubscribe Loc.LanguageChanged and worker events (memory leaks).
- IslandsShellViewModel: dispose the three System.Timers.Timer instances.
- StopAsync: guard on Task/IsRunning/IsConnected and wrap CancelTask in try/catch.
- TaskMatchesList virtual:review now matches WaitingForReview (aligns with ReviewFilter).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-06-09 09:53:58 +02:00
parent 00a065bf7f
commit 01e0c1d794
4 changed files with 72 additions and 15 deletions

View File

@@ -46,13 +46,21 @@ public sealed class LogLineViewModel
}; };
} }
public sealed partial class DetailsIslandViewModel : ViewModelBase public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
{ {
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IWorkerClient _worker; private readonly IWorkerClient _worker;
private readonly IServiceProvider _services; private readonly IServiceProvider _services;
private readonly INotesApi _notesApi; private readonly INotesApi _notesApi;
// Captured handler delegates for disposal
private readonly EventHandler _langChangedHandler;
private readonly System.ComponentModel.PropertyChangedEventHandler _workerPropertyChangedHandler;
private readonly Action<string, string, DateTime> _workerTaskStartedHandler;
private readonly Action<string, string, string, DateTime> _workerTaskFinishedHandler;
private readonly Action<string> _workerWorktreeUpdatedHandler;
private readonly Action<string> _workerTaskUpdatedHandler;
[ObservableProperty] private bool _isNotesMode; [ObservableProperty] private bool _isNotesMode;
[ObservableProperty] private bool _isPrepMode; [ObservableProperty] private bool _isPrepMode;
[ObservableProperty] private bool _isPrepRunning; [ObservableProperty] private bool _isPrepRunning;
@@ -501,13 +509,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
_notesApi = notesApi; _notesApi = notesApi;
Notes = new NotesEditorViewModel(_notesApi); Notes = new NotesEditorViewModel(_notesApi);
Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged(); Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged();
Loc.LanguageChanged += (_, _) => _langChangedHandler = (_, _) =>
{ {
OnPropertyChanged(nameof(AgentStatusLabel)); OnPropertyChanged(nameof(AgentStatusLabel));
RecomputeModelBadge(); RecomputeModelBadge();
RecomputeTurnsBadge(); RecomputeTurnsBadge();
RecomputeAgentBadge(); RecomputeAgentBadge();
}; };
Loc.LanguageChanged += _langChangedHandler;
// Subscribe once; filter by current task id inside the handler // Subscribe once; filter by current task id inside the handler
_worker.TaskMessageEvent += OnTaskMessage; _worker.TaskMessageEvent += OnTaskMessage;
@@ -516,7 +525,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
_worker.PrepFinishedEvent += OnPrepFinished; _worker.PrepFinishedEvent += OnPrepFinished;
// Re-evaluate CanExecute when worker connection flips. // Re-evaluate CanExecute when worker connection flips.
_worker.PropertyChanged += (_, e) => _workerPropertyChangedHandler = (_, e) =>
{ {
if (e.PropertyName == nameof(WorkerClient.IsConnected)) if (e.PropertyName == nameof(WorkerClient.IsConnected))
{ {
@@ -526,14 +535,17 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
ContinueCommand.NotifyCanExecuteChanged(); ContinueCommand.NotifyCanExecuteChanged();
} }
}; };
_worker.PropertyChanged += _workerPropertyChangedHandler;
// If the task row's live status changes (e.g. TaskStarted/Finished), mirror it. // If the task row's live status changes (e.g. TaskStarted/Finished), mirror it.
_worker.TaskStartedEvent += (slot, taskId, startedAt) => _workerTaskStartedHandler = (slot, taskId, startedAt) =>
{ {
if (Task?.Id == taskId) AgentState = "running"; if (Task?.Id == taskId) AgentState = "running";
_ = RefreshChildOutcomeAsync(taskId); _ = RefreshChildOutcomeAsync(taskId);
}; };
_worker.TaskFinishedEvent += (slot, taskId, status, finishedAt) => _worker.TaskStartedEvent += _workerTaskStartedHandler;
_workerTaskFinishedHandler = (slot, taskId, status, finishedAt) =>
{ {
if (Task?.Id != taskId) return; if (Task?.Id != taskId) return;
FlushClaudeBuffer(); FlushClaudeBuffer();
@@ -548,20 +560,23 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
_ = RefreshChildOutcomeAsync(taskId); _ = RefreshChildOutcomeAsync(taskId);
_ = RefreshOutcomeAsync(taskId); _ = RefreshOutcomeAsync(taskId);
}; };
_worker.TaskFinishedEvent += _workerTaskFinishedHandler;
_worker.WorktreeUpdatedEvent += taskId => _workerWorktreeUpdatedHandler = taskId =>
{ {
if (Task?.Id == taskId) _ = RefreshWorktreeAsync(taskId); if (Task?.Id == taskId) _ = RefreshWorktreeAsync(taskId);
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId); if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
_ = RefreshChildOutcomeAsync(taskId); _ = RefreshChildOutcomeAsync(taskId);
}; };
_worker.WorktreeUpdatedEvent += _workerWorktreeUpdatedHandler;
_worker.TaskUpdatedEvent += taskId => _workerTaskUpdatedHandler = taskId =>
{ {
if (Task?.Id == taskId) _ = RefreshStatusAsync(taskId); if (Task?.Id == taskId) _ = RefreshStatusAsync(taskId);
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId); if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
_ = RefreshChildOutcomeAsync(taskId); _ = RefreshChildOutcomeAsync(taskId);
}; };
_worker.TaskUpdatedEvent += _workerTaskUpdatedHandler;
Subtasks.CollectionChanged += (_, _) => Subtasks.CollectionChanged += (_, _) =>
{ {
@@ -579,6 +594,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
PrepLog.CollectionChanged += (_, _) => OnPropertyChanged(nameof(ShowPrepEmptyState)); PrepLog.CollectionChanged += (_, _) => OnPropertyChanged(nameof(ShowPrepEmptyState));
} }
public void Dispose()
{
Loc.LanguageChanged -= _langChangedHandler;
_worker.PropertyChanged -= _workerPropertyChangedHandler;
_worker.TaskStartedEvent -= _workerTaskStartedHandler;
_worker.TaskFinishedEvent -= _workerTaskFinishedHandler;
_worker.WorktreeUpdatedEvent -= _workerWorktreeUpdatedHandler;
_worker.TaskUpdatedEvent -= _workerTaskUpdatedHandler;
_worker.TaskMessageEvent -= OnTaskMessage;
_worker.PrepStartedEvent -= OnPrepStarted;
_worker.PrepLineEvent -= OnPrepLine;
_worker.PrepFinishedEvent -= OnPrepFinished;
}
private void OnTaskMessage(string taskId, string line) private void OnTaskMessage(string taskId, string line)
{ {
if (taskId != _subscribedTaskId) return; if (taskId != _subscribedTaskId) return;
@@ -1412,8 +1441,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
private async System.Threading.Tasks.Task StopAsync() private async System.Threading.Tasks.Task StopAsync()
{ {
if (Task == null) return; if (Task == null || !IsRunning) return;
await _worker.CancelTaskAsync(Task.Id); if (!_worker.IsConnected) return;
try { await _worker.CancelTaskAsync(Task.Id); }
catch { /* offline */ }
} }
[RelayCommand(CanExecute = nameof(CanEnqueue))] [RelayCommand(CanExecute = nameof(CanEnqueue))]

View File

@@ -16,7 +16,7 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
public enum ListKind { Smart, Virtual, User } public enum ListKind { Smart, Virtual, User }
public sealed partial class ListsIslandViewModel : ViewModelBase public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
{ {
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IServiceProvider? _services; private readonly IServiceProvider? _services;
@@ -141,6 +141,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
public string MachineNameLocal => Loc.T("vm.lists.localSuffix", MachineName); public string MachineNameLocal => Loc.T("vm.lists.localSuffix", MachineName);
public string UserInitials { get; } public string UserInitials { get; }
private readonly EventHandler _langChangedHandler;
public ListsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IServiceProvider? services = null, WorkerClient? worker = null) public ListsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IServiceProvider? services = null, WorkerClient? worker = null)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
@@ -163,7 +165,13 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
_worker.ConnectionRestoredEvent += () => _ = RefreshCountsAsync(); _worker.ConnectionRestoredEvent += () => _ = RefreshCountsAsync();
} }
Loc.LanguageChanged += (_, _) => RefreshLocalizedLabels(); _langChangedHandler = (_, _) => RefreshLocalizedLabels();
Loc.LanguageChanged += _langChangedHandler;
}
public void Dispose()
{
Loc.LanguageChanged -= _langChangedHandler;
} }
private static string? SmartListNameKey(string id) => id switch private static string? SmartListNameKey(string id) => id switch

View File

@@ -14,7 +14,7 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels.Islands; namespace ClaudeDo.Ui.ViewModels.Islands;
public sealed partial class TasksIslandViewModel : ViewModelBase public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
{ {
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IWorkerClient? _worker; private readonly IWorkerClient? _worker;
@@ -71,6 +71,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
public Func<UnfinishedPlanningModalViewModel, Task>? ShowUnfinishedPlanningModal { get; set; } public Func<UnfinishedPlanningModalViewModel, Task>? ShowUnfinishedPlanningModal { get; set; }
private readonly EventHandler _langChangedHandler;
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient? worker = null) public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient? worker = null)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
@@ -85,7 +87,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
_worker.RefineStartedEvent += OnRefineStarted; _worker.RefineStartedEvent += OnRefineStarted;
_worker.RefineFinishedEvent += OnRefineFinished; _worker.RefineFinishedEvent += OnRefineFinished;
} }
Loc.LanguageChanged += (_, _) => RefreshLocalizedText(); _langChangedHandler = (_, _) => RefreshLocalizedText();
Loc.LanguageChanged += _langChangedHandler;
}
public void Dispose()
{
Loc.LanguageChanged -= _langChangedHandler;
} }
private void RefreshLocalizedText() private void RefreshLocalizedText()
@@ -178,7 +186,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
ListKind.Smart when list.Id == "smart:my-day" => t.IsMyDay, ListKind.Smart when list.Id == "smart:my-day" => t.IsMyDay,
ListKind.Smart when list.Id == "smart:important" => t.IsStarred, ListKind.Smart when list.Id == "smart:important" => t.IsStarred,
ListKind.Smart when list.Id == "smart:planned" => t.ScheduledFor != null, ListKind.Smart when list.Id == "smart:planned" => t.ScheduledFor != null,
ListKind.Virtual when list.Id == "virtual:review" => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active && t.ParentTaskId == null, ListKind.Virtual when list.Id == "virtual:review" => t.Status == TaskStatus.WaitingForReview,
ListKind.User => $"user:{t.ListId}" == list.Id, ListKind.User => $"user:{t.ListId}" == list.Id,
_ => false, _ => false,
}; };

View File

@@ -15,7 +15,7 @@ using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels; namespace ClaudeDo.Ui.ViewModels;
public sealed partial class IslandsShellViewModel : ViewModelBase public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
{ {
public ListsIslandViewModel? Lists { get; } public ListsIslandViewModel? Lists { get; }
public TasksIslandViewModel? Tasks { get; } public TasksIslandViewModel? Tasks { get; }
@@ -268,6 +268,16 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
}); });
} }
public void Dispose()
{
_clearTimer.Stop();
_clearTimer.Dispose();
_connectTimer.Stop();
_connectTimer.Dispose();
_primeStatusTimer.Stop();
_primeStatusTimer.Dispose();
}
private void RefreshBannerFromStatus() private void RefreshBannerFromStatus()
{ {
switch (_updateCheck.LastCheckStatus) switch (_updateCheck.LastCheckStatus)