diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs index 9c359ccf..384e7d14 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs @@ -28,12 +28,19 @@ public sealed partial class TaskRowViewModel : ViewModelBase [ObservableProperty] private string? _parentTaskId; [ObservableProperty] private string? _createdBy; [ObservableProperty] private string? _blockedByTaskId; + [ObservableProperty] private string? _dependsOnTaskId; [ObservableProperty] private bool _isExpanded = true; [ObservableProperty] private bool _hasPlanningChildren; [ObservableProperty] private bool _hasQueuedSubtasks; [ObservableProperty] private bool _showListChip = true; [ObservableProperty] private bool _parentFinalized; [ObservableProperty] private bool _parentInView = true; + // The three below are extrinsic — computed and assigned by TasksIslandViewModel.Regroup + // (it alone has the cross-row graph needed to walk DependsOnTaskId chains), mirroring how + // ParentInView is assigned by the same pass rather than derived locally. + [ObservableProperty] private bool _showAsChainMember; + [ObservableProperty] private int? _chainStep; + [ObservableProperty] private string? _chainAfterLabel; [ObservableProperty] private int _roadblockCount; // Only meaningful when Status=Failed; stamped by TaskRunner.MarkFailed. "unknown" for a // Failed task that predates this field. Null on every other status. @@ -370,6 +377,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase ParentTaskId = t.ParentTaskId; CreatedBy = t.CreatedBy; BlockedByTaskId = t.BlockedByTaskId; + DependsOnTaskId = t.DependsOnTaskId; RoadblockCount = t.RoadblockCount; FailureReason = t.FailureReason; FailureTurnsUsed = t.FailureTurnsUsed; diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index e2671d06..cec83175 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -580,9 +580,124 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable open.Add(r); } + // Dependency chains are resolved and pulled together per section (not on the + // pre-split `flat` list): a chain head might land in a different section than its + // dependent (e.g. a Done head in Completed, an open dependent in Open) — in that case + // the two are never rendered adjacent, so the dependent must fall back to a flat row + // with a label rather than an orphaned rail. Whether the head is resolvable at all + // still uses the whole-Items graph (rowsById), independent of section. + var rowsById = Items.ToDictionary(r => r.Id); + overdue = ApplyChainGrouping(overdue, rowsById); + open = ApplyChainGrouping(open, rowsById); + completed = ApplyChainGrouping(completed, rowsById); + return (overdue, open, completed); } + // Cycles are rejected by TaskStateService.SetDependsOnAsync, so this should always + // terminate quickly — capped anyway against a corrupt/legacy row forming a loop. + private const int MaxChainWalkDepth = 64; + + private readonly record struct ChainWalkResult(TaskRowViewModel Head, int Step); + + // Walks DependsOnTaskId back to its root. Returns null when the chain can't be resolved — + // either the row has no predecessor at all, or a predecessor along the way isn't loaded + // into the current Items (e.g. filtered out of this list/view). + private static ChainWalkResult? WalkChainHead(TaskRowViewModel row, Dictionary rowsById) + { + var current = row; + var step = 0; + var visited = new HashSet { row.Id }; + while (!string.IsNullOrEmpty(current.DependsOnTaskId)) + { + if (!rowsById.TryGetValue(current.DependsOnTaskId, out var predecessor)) return null; + if (step >= MaxChainWalkDepth || !visited.Add(predecessor.Id)) return null; + current = predecessor; + step++; + } + return step == 0 ? null : new ChainWalkResult(current, step); + } + + // Content only — "after " + localization is Slice 2's concern (the view), which is why this + // holds just the identifying fragment rather than an assembled sentence. + private static string? BuildAfterLabel(TaskRowViewModel row, Dictionary rowsById) + { + if (string.IsNullOrEmpty(row.DependsOnTaskId)) return null; + if (!rowsById.TryGetValue(row.DependsOnTaskId, out var predecessor)) return null; + return predecessor.Number > 0 ? $"#{predecessor.Number}" : predecessor.Title; + } + + // Assigns ShowAsChainMember/ChainStep/ChainAfterLabel for every row in `section`, then + // returns a re-ordered copy with chain dependents pulled directly under their head + // (ascending ChainStep), regardless of their SortOrder-derived position — mirroring how + // planning children already sit right after their parent regardless of Items order. + private static List ApplyChainGrouping( + List section, Dictionary rowsById) + { + var sectionIds = section.Select(r => r.Id).ToHashSet(); + var headIdOf = new Dictionary(); + var stepOf = new Dictionary(); + + foreach (var r in section) + { + if (string.IsNullOrEmpty(r.DependsOnTaskId)) + { + r.ShowAsChainMember = false; + r.ChainStep = null; + r.ChainAfterLabel = null; + continue; + } + + // A planning child keeps its parent indent — chain membership only ever shows as a + // label for it, never as a second, nested rail (design: "parent wins"). + if (r.IsChild) + { + r.ShowAsChainMember = false; + r.ChainStep = null; + r.ChainAfterLabel = BuildAfterLabel(r, rowsById); + continue; + } + + var walk = WalkChainHead(r, rowsById); + if (walk is { } w && sectionIds.Contains(w.Head.Id)) + { + r.ShowAsChainMember = true; + r.ChainStep = w.Step; + r.ChainAfterLabel = null; + headIdOf[r] = w.Head.Id; + stepOf[r] = w.Step; + } + else + { + r.ShowAsChainMember = false; + r.ChainStep = null; + r.ChainAfterLabel = BuildAfterLabel(r, rowsById); + } + } + + if (headIdOf.Count == 0) return section; + + var membersByHeadId = section + .Where(headIdOf.ContainsKey) + .GroupBy(r => headIdOf[r]) + .ToDictionary(g => g.Key, g => g.OrderBy(r => stepOf[r]).ToList()); + + var ordered = new List(section.Count); + var consumed = new HashSet(); + foreach (var r in section) + { + if (consumed.Contains(r.Id)) continue; + if (headIdOf.ContainsKey(r)) continue; // placed via its head below, in step order + ordered.Add(r); + consumed.Add(r.Id); + if (membersByHeadId.TryGetValue(r.Id, out var members)) + foreach (var m in members) + if (consumed.Add(m.Id)) + ordered.Add(m); + } + return ordered; + } + private void UpdateSubtitle() { var now = DateTime.Now; diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandChainTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandChainTests.cs new file mode 100644 index 00000000..d9b8d037 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandChainTests.cs @@ -0,0 +1,245 @@ +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; + +// DependsOnTaskId chain display — Slice 1 (VM + Regroup grouping only, no AXAML). Design: +// docs/superpowers/specs/2026-08-11-dependency-chain-display-design.md +public class TasksIslandChainTests : IDisposable +{ + private readonly string _dbPath; + + public TasksIslandChainTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ui_test_{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 TasksIslandViewModel BuildViewModel() + { + var factory = new TestDbFactory(NewContext); + return new TasksIslandViewModel(factory, worker: null); + } + + private static ListNavItemViewModel UserList(string listEntityId, string name) => + new() { Id = $"user:{listEntityId}", Kind = ListKind.User, Name = name }; + + private static async Task LoadAndWaitAsync(TasksIslandViewModel vm, ListNavItemViewModel list) + { + vm.LoadForList(list); + var deadline = DateTime.UtcNow.AddSeconds(5); + while (DateTime.UtcNow < deadline) + { + await Task.Delay(25); + if (vm.Items.Count > 0) break; + } + await Task.Delay(50); + } + + private async Task> SeedAsync( + params (string Id, TaskStatus Status, string? DependsOnTaskId, string? ParentTaskId, int SortOrder)[] tasks) + { + var numbers = new Dictionary(); + await using var db = NewContext(); + db.Lists.Add(new ListEntity { Id = "list1", Name = "Default", CreatedAt = DateTime.UtcNow }); + foreach (var t in tasks) + { + var number = TestTaskNumbers.Next(); + numbers[t.Id] = number; + db.Tasks.Add(new TaskEntity + { + Number = number, + Id = t.Id, + ListId = "list1", + Title = t.Id, + CreatedAt = DateTime.UtcNow, + Status = t.Status, + DependsOnTaskId = t.DependsOnTaskId, + ParentTaskId = t.ParentTaskId, + SortOrder = t.SortOrder, + }); + } + await db.SaveChangesAsync(); + return numbers; + } + + [Fact] + public async Task LinearChain_StepsIncreaseWithHopDistance() + { + await SeedAsync( + ("head", TaskStatus.Idle, null, null, 0), + ("s1", TaskStatus.Idle, "head", null, 1), + ("s2", TaskStatus.Idle, "s1", null, 2), + ("s3", TaskStatus.Idle, "s2", null, 3)); + + var vm = BuildViewModel(); + await LoadAndWaitAsync(vm, UserList("list1", "Default")); + + var head = vm.Items.First(r => r.Id == "head"); + var s1 = vm.Items.First(r => r.Id == "s1"); + var s2 = vm.Items.First(r => r.Id == "s2"); + var s3 = vm.Items.First(r => r.Id == "s3"); + + Assert.False(head.ShowAsChainMember); + Assert.Null(head.ChainStep); + + Assert.True(s1.ShowAsChainMember); + Assert.Equal(1, s1.ChainStep); + Assert.True(s2.ShowAsChainMember); + Assert.Equal(2, s2.ChainStep); + Assert.True(s3.ShowAsChainMember); + Assert.Equal(3, s3.ChainStep); + } + + [Fact] + public async Task TwoTasksSamePredecessor_ShareTheSameStep() + { + await SeedAsync( + ("head", TaskStatus.Idle, null, null, 0), + ("s1", TaskStatus.Idle, "head", null, 1), + ("s2a", TaskStatus.Idle, "s1", null, 2), + ("s2b", TaskStatus.Idle, "s1", null, 3)); + + var vm = BuildViewModel(); + await LoadAndWaitAsync(vm, UserList("list1", "Default")); + + var s2a = vm.Items.First(r => r.Id == "s2a"); + var s2b = vm.Items.First(r => r.Id == "s2b"); + + Assert.True(s2a.ShowAsChainMember); + Assert.True(s2b.ShowAsChainMember); + Assert.Equal(2, s2a.ChainStep); + Assert.Equal(2, s2b.ChainStep); + } + + [Fact] + public async Task HeadInDifferentSection_DependentRendersFlatWithLabel() + { + // Same list, but "head" is Done (-> Completed section) while its dependent stays open. + // The two are never adjacent, so the dependent must not claim group membership. + var numbers = await SeedAsync( + ("head", TaskStatus.Done, null, null, 0), + ("dependent", TaskStatus.Idle, "head", null, 1)); + + var vm = BuildViewModel(); + await LoadAndWaitAsync(vm, UserList("list1", "Default")); + + var dependent = vm.Items.First(r => r.Id == "dependent"); + + Assert.False(dependent.ShowAsChainMember); + Assert.Null(dependent.ChainStep); + Assert.NotNull(dependent.ChainAfterLabel); + Assert.Contains(numbers["head"].ToString(), dependent.ChainAfterLabel); + } + + [Fact] + public async Task PlanningChildWithDependsOnTaskId_NeverBecomesChainMember() + { + await SeedAsync( + ("predecessor", TaskStatus.Idle, null, null, 0), + ("parent", TaskStatus.Idle, null, null, 1), + ("child", TaskStatus.Idle, "predecessor", "parent", 2)); + + var vm = BuildViewModel(); + await LoadAndWaitAsync(vm, UserList("list1", "Default")); + + // Planning parents with children collapse by default — expand so the child is part of + // the flat list Regroup actually classifies (and thus reaches chain-grouping at all). + vm.ToggleExpandCommand.Execute(vm.Items.First(r => r.Id == "parent")); + + var child = vm.Items.First(r => r.Id == "child"); + + Assert.True(child.IsChild); + Assert.False(child.ShowAsChainMember); + Assert.Null(child.ChainStep); + Assert.NotNull(child.ChainAfterLabel); + } + + [Fact] + public async Task ChainGroup_IsPulledTogether_RegardlessOfSortOrder() + { + // "head" sorts after its dependents by SortOrder — grouping must still put the + // dependents directly after the head in Rows. + await SeedAsync( + ("s1", TaskStatus.Idle, "head", null, 0), + ("unrelated", TaskStatus.Idle, null, null, 1), + ("head", TaskStatus.Idle, null, null, 2)); + + var vm = BuildViewModel(); + await LoadAndWaitAsync(vm, UserList("list1", "Default")); + + var rows = vm.Rows.OfType().Select(r => r.Id).ToList(); + var headIdx = rows.IndexOf("head"); + var s1Idx = rows.IndexOf("s1"); + + Assert.Equal(headIdx + 1, s1Idx); + } + + [Fact] + public async Task TaskWithoutDependencyOrDependents_IsUnaffected() + { + await SeedAsync(("lonely", TaskStatus.Idle, null, null, 0)); + + var vm = BuildViewModel(); + await LoadAndWaitAsync(vm, UserList("list1", "Default")); + + var lonely = vm.Items.First(r => r.Id == "lonely"); + + Assert.False(lonely.ShowAsChainMember); + Assert.Null(lonely.ChainStep); + Assert.Null(lonely.ChainAfterLabel); + } + + [Fact] + public async Task ChainProperties_RecomputeAfterTaskUpdated() + { + await SeedAsync( + ("head", TaskStatus.Idle, null, null, 0), + ("later", TaskStatus.Idle, null, null, 1)); + + var vm = BuildViewModel(); + await LoadAndWaitAsync(vm, UserList("list1", "Default")); + + var later = vm.Items.First(r => r.Id == "later"); + Assert.False(later.ShowAsChainMember); + + await using (var db = NewContext()) + { + var entity = await db.Tasks.FirstAsync(t => t.Id == "later"); + entity.DependsOnTaskId = "head"; + await db.SaveChangesAsync(); + } + + await vm.RefreshTaskFromWorkerAsync("later"); + + var updated = vm.Items.First(r => r.Id == "later"); + Assert.True(updated.ShowAsChainMember); + Assert.Equal(1, updated.ChainStep); + } +}