From ab56644ddca996ff84705dba0298049c260dcf03 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 30 Jul 2026 15:19:39 +0200 Subject: [PATCH] fix(ui): clear task selection when switching lists Switching lists rebuilt Tasks.Items from scratch but left SelectedTask pointing at a row from the previous list, so the detail pane kept showing that task even though it is not in the visible list. Drop the selection when the list actually changes; a reload of the same list (worker refresh, reconnect) keeps it so a live update never yanks the detail pane away. --- .../Islands/TasksIslandViewModel.cs | 7 ++ .../TasksIslandListSwitchSelectionTests.cs | 112 ++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandListSwitchSelectionTests.cs diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index d8446c82..35e5947e 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -231,6 +231,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable _loadCts = new CancellationTokenSource(); var ct = _loadCts.Token; + // Items is rebuilt from scratch below, so a selection carried over from the previous list + // would leave the detail pane bound to a task the visible list no longer contains. Only a + // *different* list drops it — a reload of the same list (worker refresh, reconnect) keeps + // the selection so a live update never yanks the detail pane away. + var listChanged = !string.Equals(_currentList?.Id, list?.Id, StringComparison.Ordinal); + if (_currentList is not null) _currentList.PropertyChanged -= OnCurrentListPropertyChanged; _currentList = list; @@ -246,6 +252,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable HasCompleted = false; ShowOpenLabel = false; ShowNotesRow = false; + if (listChanged) SelectedTask = null; if (list is null) { IsLetClaudeVisible = false; LoadTask = Task.CompletedTask; return; } HeaderTitle = list.Name; diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandListSwitchSelectionTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandListSwitchSelectionTests.cs new file mode 100644 index 00000000..04f4114a --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandListSwitchSelectionTests.cs @@ -0,0 +1,112 @@ +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; + +// Switching lists must drop the previous list's selection; reloading the SAME list (worker +// refresh / reconnect) must keep it, so a live update never yanks the detail pane away. +public class TasksIslandListSwitchSelectionTests : IDisposable +{ + private readonly string _dbPath; + + public TasksIslandListSwitchSelectionTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ui_listswitch_{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 async Task SeedTwoListsAsync() + { + await using var db = NewContext(); + db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); + db.Lists.Add(new ListEntity { Id = "L2", Name = "Home", CreatedAt = DateTime.UtcNow }); + db.Tasks.Add(new TaskEntity + { + Id = "T1", ListId = "L1", Title = "Task one", + Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 0, + }); + db.Tasks.Add(new TaskEntity + { + Id = "T2", ListId = "L2", Title = "Task two", + Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 0, + }); + await db.SaveChangesAsync(); + } + + private static ListNavItemViewModel Nav(string listId, string name) => + new() { Id = $"user:{listId}", Name = name, Kind = ListKind.User }; + + [Fact] + public async Task LoadForList_OtherList_ClearsSelectedTask_AndRaisesSelectionChanged() + { + await SeedTwoListsAsync(); + var vm = new TasksIslandViewModel(new TestDbFactory(NewContext), worker: null); + + vm.LoadForList(Nav("L1", "Work")); + Assert.True(await vm.SelectByIdAsync("T1")); + + var selectionChanges = 0; + vm.SelectionChanged += (_, _) => selectionChanges++; + + vm.LoadForList(Nav("L2", "Home")); + if (vm.LoadTask is { } load) await load; + + Assert.Null(vm.SelectedTask); + Assert.Equal(1, selectionChanges); + } + + [Fact] + public async Task LoadForList_NullList_ClearsSelectedTask() + { + await SeedTwoListsAsync(); + var vm = new TasksIslandViewModel(new TestDbFactory(NewContext), worker: null); + + vm.LoadForList(Nav("L1", "Work")); + Assert.True(await vm.SelectByIdAsync("T1")); + + vm.LoadForList(null); + + Assert.Null(vm.SelectedTask); + } + + [Fact] + public async Task LoadForList_SameList_KeepsSelectedTask() + { + await SeedTwoListsAsync(); + var vm = new TasksIslandViewModel(new TestDbFactory(NewContext), worker: null); + + vm.LoadForList(Nav("L1", "Work")); + Assert.True(await vm.SelectByIdAsync("T1")); + + vm.LoadForList(Nav("L1", "Work")); + if (vm.LoadTask is { } load) await load; + + Assert.Equal("T1", vm.SelectedTask?.Id); + } +}