From 3a07b319f9746548048172e4d14257a9e71abbe6 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 5 Aug 2026 09:19:10 +0200 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFfix(claude-do):=20Fix:=20Statuswechsel?= =?UTF-8?q?=20schl=C3=A4gt=20in=20Task-Row=20und=20Detail-Pane=20erst?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem (vom Nutzer bestätigt am 2026-08-05) Wechselt ein Task den Status, bleiben zwei Stellen stehen, bis man die Liste wechselt und zurückwechselt: 1. **Task-Row in der offenen Liste** (mittlere Insel) — Status-Chip und Gruppen-Einordnung aktualisieren sich nicht. 2. **Detail-Pane des ausgewählten Tasks** (rechte Insel) — Status, Review-Buttons und Header-Bar zeigen weiter den alten Zustand ClaudeDo-Task: 181d4abf368046b99bd57d5e2a7ab97a --- .../Islands/DetailsIslandViewModel.cs | 25 ++++ .../Islands/TasksIslandViewModel.cs | 22 ++- .../DetailsIslandTaskUpdatedTests.cs | 128 ++++++++++++++++++ .../TasksIslandFullReloadSelectionTests.cs | 102 ++++++++++++++ 4 files changed, 274 insertions(+), 3 deletions(-) create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTaskUpdatedTests.cs create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandFullReloadSelectionTests.cs diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 93ea60ac..87dfe9c8 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -363,6 +363,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable { if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId); _ = RefreshChildOutcomeAsync(taskId); + // The bound row's own status can change from outside (worker run, MCP tool, another + // session) — refresh it directly instead of relying on TasksIslandViewModel mutating + // the same instance, so the detail pane stays correct even if that row was replaced. + if (Task?.Id == taskId) _ = RefreshBoundTaskAsync(taskId); }; _worker.TaskUpdatedEvent += _workerTaskUpdatedHandler; @@ -764,6 +768,27 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable catch { /* best-effort */ } } + // Refreshes the bound row itself (status, planning phase, worktree/branch mirrors, etc.) from + // the DB. Kept independent of TasksIslandViewModel's own handler: the row instance backing + // Task may have been replaced by a full list reload, so this must not assume it stayed live. + private async System.Threading.Tasks.Task RefreshBoundTaskAsync(string taskId) + { + try + { + await using var ctx = await _dbFactory.CreateDbContextAsync(); + var entity = await ctx.Tasks + .AsNoTracking() + .Include(t => t.Worktree) + .Include(t => t.List) + .FirstOrDefaultAsync(t => t.Id == taskId); + if (entity is null || Task?.Id != taskId) return; + Task.UpdateFromEntity(entity); + OnPropertyChanged(nameof(CanPickUpInTerminal)); + OnPropertyChanged(nameof(CanAcceptDrop)); + } + catch { /* best-effort */ } + } + private async System.Threading.Tasks.Task RefreshWorktreeAsync(string taskId) { try diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index 35e5947e..3c06b3b8 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -237,6 +237,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable // the selection so a live update never yanks the detail pane away. var listChanged = !string.Equals(_currentList?.Id, list?.Id, StringComparison.Ordinal); + // A same-list reload (triggered by OnWorkerTaskUpdated's full-reload branches) rebuilds + // every row from scratch. Reusing the previous instances by id — instead of handing back + // brand new ones — keeps SelectedTask (and the bound DetailsIslandViewModel.Task) pointed + // at a live row instead of an orphan that never receives another update. + var reusable = listChanged ? null : Items.ToDictionary(r => r.Id); + if (_currentList is not null) _currentList.PropertyChanged -= OnCurrentListPropertyChanged; _currentList = list; @@ -261,10 +267,11 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable IsMyDayList = list.Id == "smart:my-day"; IsLetClaudeVisible = list.Kind == ListKind.User && !string.IsNullOrWhiteSpace(list.WorkingDir); - LoadTask = LoadForListAsync(list, ct); + LoadTask = LoadForListAsync(list, ct, reusable); } - private async Task LoadForListAsync(ListNavItemViewModel list, CancellationToken ct) + private async Task LoadForListAsync( + ListNavItemViewModel list, CancellationToken ct, Dictionary? reusable) { try { @@ -292,7 +299,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable var showListChip = list.Kind == ListKind.Virtual; foreach (var t in filteredList) { - var row = TaskRowViewModel.FromEntity(t); + TaskRowViewModel row; + if (reusable is not null && reusable.TryGetValue(t.Id, out var existing)) + { + row = existing; + row.UpdateFromEntity(t); + } + else + { + row = TaskRowViewModel.FromEntity(t); + } row.ShowListChip = showListChip; row.HasInteractiveSession = _interactiveSessionIds.Contains(row.Id); Items.Add(row); diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTaskUpdatedTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTaskUpdatedTests.cs new file mode 100644 index 00000000..925917ea --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTaskUpdatedTests.cs @@ -0,0 +1,128 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Islands; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +// A status change on the task bound in the detail pane can come from outside the UI (worker run, +// MCP tool, another session) and must refresh the pane's Task without waiting for a list reload. +public class DetailsIslandTaskUpdatedTests : IDisposable +{ + private readonly string _dbPath; + + public DetailsIslandTaskUpdatedTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_details_taskupdated_{Guid.NewGuid():N}.db"); + using var ctx = NewContext(); + ctx.Database.EnsureCreated(); + } + + public void Dispose() + { + try { File.Delete(_dbPath); } catch { } + try { File.Delete(_dbPath + "-wal"); } catch { } + try { File.Delete(_dbPath + "-shm"); } catch { } + } + + private ClaudeDoDbContext NewContext() + { + var opts = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}") + .Options; + return new ClaudeDoDbContext(opts); + } + + private sealed class TestDbFactory : IDbContextFactory + { + private readonly Func _create; + public TestDbFactory(Func create) => _create = create; + public ClaudeDoDbContext CreateDbContext() => _create(); + } + + private sealed class NullServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + + private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi + { + public Task> ListAsync(DateOnly day) => + Task.FromResult(new List()); + public Task AddAsync(DateOnly day, string text) => + Task.FromResult(null); + public Task UpdateAsync(string id, string text) => Task.CompletedTask; + public Task DeleteAsync(string id) => Task.CompletedTask; + } + + private sealed class FakeWorker : StubWorkerClient + { + public override bool IsConnected => true; + } + + private async Task SeedTaskAsync(string id, TaskStatus status) + { + await using var db = NewContext(); + if (!await db.Lists.AnyAsync(l => l.Id == "L1")) + db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); + db.Tasks.Add(new TaskEntity + { + Id = id, ListId = "L1", Title = "Task", Status = status, CreatedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + } + + private DetailsIslandViewModel BuildVm(StubWorkerClient worker) => + new(new TestDbFactory(NewContext), worker, new NullServiceProvider(), new StubNotesApi(), + new ClaudeDo.Ui.Services.MergeCoordinator()); + + [Fact] + public async Task TaskUpdated_ForBoundTask_RefreshesStatusAndPickUpInTerminal() + { + await SeedTaskAsync("t-bound-1", TaskStatus.Running); + + var worker = new FakeWorker(); + var vm = BuildVm(worker); + vm.Bind(new TaskRowViewModel { Id = "t-bound-1", Status = TaskStatus.Running }); + await Task.Delay(50); + + Assert.False(vm.CanPickUpInTerminal); + + await using (var db = NewContext()) + { + (await db.Tasks.FirstAsync(t => t.Id == "t-bound-1")).Status = TaskStatus.WaitingForReview; + await db.SaveChangesAsync(); + } + worker.RaiseTaskUpdated("t-bound-1"); + + var deadline = DateTime.UtcNow.AddSeconds(3); + while (DateTime.UtcNow < deadline && vm.Task?.Status != TaskStatus.WaitingForReview) + await Task.Delay(25); + + Assert.Equal(TaskStatus.WaitingForReview, vm.Task?.Status); + Assert.True(vm.CanPickUpInTerminal); + } + + [Fact] + public async Task TaskUpdated_ForADifferentTask_DoesNotTouchBoundTask() + { + await SeedTaskAsync("t-bound-2", TaskStatus.Running); + await SeedTaskAsync("t-other-2", TaskStatus.Idle); + + var worker = new FakeWorker(); + var vm = BuildVm(worker); + vm.Bind(new TaskRowViewModel { Id = "t-bound-2", Status = TaskStatus.Running }); + + await using (var db = NewContext()) + { + (await db.Tasks.FirstAsync(t => t.Id == "t-other-2")).Status = TaskStatus.Done; + await db.SaveChangesAsync(); + } + worker.RaiseTaskUpdated("t-other-2"); + await Task.Delay(150); + + Assert.Equal(TaskStatus.Running, vm.Task?.Status); + } +} diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandFullReloadSelectionTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandFullReloadSelectionTests.cs new file mode 100644 index 00000000..cd1afa89 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandFullReloadSelectionTests.cs @@ -0,0 +1,102 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.ViewModels.Islands; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +// OnWorkerTaskUpdated falls back to a full LoadForList reload for a planning parent (or any task +// with children) because the delta path can't recompute the child-derived flags on its own. That +// reload used to hand back brand new TaskRowViewModel instances, orphaning SelectedTask (and the +// DetailsIslandViewModel bound to it) on a live status change. See TaskUpdated_FullReload_* below. +public class TasksIslandFullReloadSelectionTests : IDisposable +{ + private readonly string _dbPath; + + public TasksIslandFullReloadSelectionTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ui_fullreload_{Guid.NewGuid():N}.db"); + using var ctx = NewContext(); + ctx.Database.EnsureCreated(); + } + + public void Dispose() + { + try { File.Delete(_dbPath); } catch { } + try { File.Delete(_dbPath + "-wal"); } catch { } + try { File.Delete(_dbPath + "-shm"); } catch { } + } + + private ClaudeDoDbContext NewContext() + { + var opts = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}") + .Options; + return new ClaudeDoDbContext(opts); + } + + private sealed class TestDbFactory : IDbContextFactory + { + private readonly Func _create; + public TestDbFactory(Func create) => _create = create; + public ClaudeDoDbContext CreateDbContext() => _create(); + } + + private sealed class FakeWorker : StubWorkerClient + { + } + + private async Task SeedPlanningParentWithChildAsync() + { + await using var db = NewContext(); + db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); + db.Tasks.Add(new TaskEntity + { + Id = "P1", ListId = "L1", Title = "Parent", + Status = TaskStatus.WaitingForChildren, PlanningPhase = PlanningPhase.Finalized, + CreatedAt = DateTime.UtcNow, SortOrder = 0, + }); + db.Tasks.Add(new TaskEntity + { + Id = "C1", ListId = "L1", ParentTaskId = "P1", Title = "Child", + Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 1, + }); + await db.SaveChangesAsync(); + } + + [Fact] + public async Task TaskUpdated_FullReload_KeepsSameSelectedInstance() + { + await SeedPlanningParentWithChildAsync(); + + var worker = new FakeWorker(); + var vm = new TasksIslandViewModel(new TestDbFactory(NewContext), worker); + vm.LoadForList(new ListNavItemViewModel { Id = "user:L1", Name = "Work", Kind = ListKind.User }); + if (vm.LoadTask is { } lt) await lt; + + Assert.True(await vm.SelectByIdAsync("P1")); + var selectedBefore = vm.SelectedTask; + Assert.NotNull(selectedBefore); + + // Child finishes, which flips the parent's status — the broadcast lands for the parent's + // id and (being a planning parent) takes the full-reload branch of OnWorkerTaskUpdated. + await using (var db = NewContext()) + { + (await db.Tasks.FirstAsync(t => t.Id == "C1")).Status = TaskStatus.Done; + (await db.Tasks.FirstAsync(t => t.Id == "P1")).Status = TaskStatus.WaitingForReview; + await db.SaveChangesAsync(); + } + worker.RaiseTaskUpdated("P1"); + + var deadline = DateTime.UtcNow.AddSeconds(3); + while (DateTime.UtcNow < deadline && vm.SelectedTask?.Status != TaskStatus.WaitingForReview) + await Task.Delay(25); + + // The reload must not have replaced the row: SelectedTask still points at the same + // instance, it just got mutated in place — so the detail pane stays bound and current. + Assert.Same(selectedBefore, vm.SelectedTask); + Assert.Equal(TaskStatus.WaitingForReview, vm.SelectedTask!.Status); + Assert.Same(vm.SelectedTask, vm.Items.First(r => r.Id == "P1")); + } +}