feat(ui): surface depends_on blocking in status chip, chain label, and detail pane
Usability-Audit 2026-09-10 found three gaps versus the dependency-chain-display spec: a Queued task blocked on an unmet task_dependencies edge rendered the same chip as one about to run, a cross-list predecessor silently dropped the "after #N" chip, and the detail pane showed nothing about a wait at all. - TaskRowViewModel gains IsBlockedByDependency/DependencyWaitLabel (extrinsic, assigned by TasksIslandViewModel like the existing chain fields); IsQueued/ IsWaiting/StatusChipClass/StatusLabel/StatusChipTooltip now account for it alongside the existing BlockedByTaskId planning-chain link. - TasksIslandViewModel.ApplyDependencyWaitState computes the blocked flag and wait label per row; a new RefreshExternalDependencyCacheAsync backfills Number/Title/Status for predecessors not loaded into the current list's Items (reusing the already-open DbContext, no new hub method), so BuildAfterLabel/blocking both resolve a listenfremder Vorgänger correctly. - New "waiting" chip style (IslandStyles.axaml) and locale keys (en+de); TaskHeaderBar shows a "Waiting on #N" line bound straight to the same row. Tests: TasksIslandChainTests (blocked/unblocked chip, cross-list predecessor), DetailsIslandDependencyWaitTests (new), TaskRowViewModelNotificationsTests updated for the new notification set.
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
// Usability-Audit 2026-09-10: the detail pane showed nothing when a task was blocked on a
|
||||
// predecessor -- the header bar now renders a "Waiting on #N" line bound straight to the same
|
||||
// Task.IsWaiting/DependencyWaitLabel TasksIslandViewModel already computes (no new VM logic here).
|
||||
public class DetailsIslandDependencyWaitTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
|
||||
public DetailsIslandDependencyWaitTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_dep_wait_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<ClaudeDoDbContext>()
|
||||
.UseSqlite($"Data Source={_dbPath}")
|
||||
.Options;
|
||||
return new ClaudeDoDbContext(opts);
|
||||
}
|
||||
|
||||
private DetailsIslandViewModel NewVm()
|
||||
{
|
||||
var factory = new TestDbFactory(NewContext);
|
||||
return new DetailsIslandViewModel(factory, new DefaultStub(), new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnblockedTask_HasNoWaitingLine()
|
||||
{
|
||||
var vm = NewVm();
|
||||
vm.Bind(new TaskRowViewModel { Id = Guid.NewGuid().ToString("N"), Number = 1, Status = TaskStatus.Queued });
|
||||
|
||||
Assert.False(vm.Task!.IsWaiting);
|
||||
Assert.Null(vm.Task.DependencyWaitLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlockedTask_ExposesWaitingLineViaBoundRow()
|
||||
{
|
||||
var vm = NewVm();
|
||||
var row = new TaskRowViewModel
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Number = 2,
|
||||
Status = TaskStatus.Queued,
|
||||
IsBlockedByDependency = true,
|
||||
DependencyWaitLabel = "#42",
|
||||
};
|
||||
|
||||
vm.Bind(row);
|
||||
|
||||
Assert.True(vm.Task!.IsWaiting);
|
||||
Assert.Equal("#42", vm.Task.DependencyWaitLabel);
|
||||
}
|
||||
}
|
||||
@@ -266,4 +266,93 @@ public class TasksIslandChainTests : IDisposable
|
||||
Assert.Contains(numbers["a"].ToString(), wiring.ChainAfterLabel);
|
||||
Assert.Contains(numbers["b"].ToString(), wiring.ChainAfterLabel);
|
||||
}
|
||||
|
||||
// Usability-Audit 2026-09-10: a Queued task blocked on an unmet task_dependencies edge used to
|
||||
// render the same "queued" chip as one that's actually next up to run.
|
||||
[Fact]
|
||||
public async Task QueuedTaskWithUnmetDependency_ShowsWaitingChipAndTooltip()
|
||||
{
|
||||
var numbers = await SeedAsync(
|
||||
("predecessor", TaskStatus.Idle, null, null, 0),
|
||||
("dependent", TaskStatus.Queued, "predecessor", null, 1));
|
||||
|
||||
var vm = BuildViewModel();
|
||||
await LoadAndWaitAsync(vm, UserList("list1", "Default"));
|
||||
|
||||
var dependent = vm.Items.First(r => r.Id == "dependent");
|
||||
|
||||
Assert.True(dependent.IsWaiting);
|
||||
Assert.False(dependent.IsQueued);
|
||||
Assert.Equal("waiting", dependent.StatusChipClass);
|
||||
Assert.NotNull(dependent.StatusChipTooltip);
|
||||
Assert.Contains(numbers["predecessor"].ToString(), dependent.StatusChipTooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueuedTaskWithSatisfiedDependency_ShowsPlainQueuedChip()
|
||||
{
|
||||
await SeedAsync(
|
||||
("predecessor", TaskStatus.Done, null, null, 0),
|
||||
("dependent", TaskStatus.Queued, "predecessor", null, 1));
|
||||
|
||||
var vm = BuildViewModel();
|
||||
await LoadAndWaitAsync(vm, UserList("list1", "Default"));
|
||||
|
||||
var dependent = vm.Items.First(r => r.Id == "dependent");
|
||||
|
||||
Assert.False(dependent.IsWaiting);
|
||||
Assert.True(dependent.IsQueued);
|
||||
Assert.Equal("queued", dependent.StatusChipClass);
|
||||
Assert.Null(dependent.DependencyWaitLabel);
|
||||
}
|
||||
|
||||
// Usability-Audit 2026-09-10: BuildAfterLabel only resolved predecessors already loaded into
|
||||
// Items, so a listenfremder Vorgänger (different list) silently dropped both the "after #N"
|
||||
// chip and the blocked chip. RefreshExternalDependencyCacheAsync backfills Number/Title/Status
|
||||
// for exactly those ids via one extra query on the already-open DbContext.
|
||||
[Fact]
|
||||
public async Task CrossListPredecessor_BlocksAndRendersAfterLabel()
|
||||
{
|
||||
var headNumber = TestTaskNumbers.Next();
|
||||
await using (var db = NewContext())
|
||||
{
|
||||
db.Lists.Add(new ListEntity { Id = "list1", Name = "Default", CreatedAt = DateTime.UtcNow });
|
||||
db.Lists.Add(new ListEntity { Id = "list2", Name = "Other", CreatedAt = DateTime.UtcNow });
|
||||
db.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Number = headNumber,
|
||||
Id = "head",
|
||||
ListId = "list1",
|
||||
Title = "head",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Status = TaskStatus.Idle,
|
||||
SortOrder = 0,
|
||||
});
|
||||
db.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Number = TestTaskNumbers.Next(),
|
||||
Id = "dependent",
|
||||
ListId = "list2",
|
||||
Title = "dependent",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Status = TaskStatus.Queued,
|
||||
SortOrder = 0,
|
||||
});
|
||||
db.TaskDependencies.Add(new TaskDependencyEntity { TaskId = "dependent", DependsOnTaskId = "head" });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var vm = BuildViewModel();
|
||||
await LoadAndWaitAsync(vm, UserList("list2", "Other"));
|
||||
|
||||
var dependent = vm.Items.First(r => r.Id == "dependent");
|
||||
|
||||
Assert.True(dependent.IsWaiting);
|
||||
Assert.False(dependent.IsQueued);
|
||||
Assert.Equal("waiting", dependent.StatusChipClass);
|
||||
Assert.NotNull(dependent.ChainAfterLabel);
|
||||
Assert.Contains($"#{headNumber}", dependent.ChainAfterLabel);
|
||||
Assert.NotNull(dependent.DependencyWaitLabel);
|
||||
Assert.Contains($"#{headNumber}", dependent.DependencyWaitLabel);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user