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:
mika kuns
2026-09-10 13:32:36 +02:00
parent c1b133ff90
commit 4cf601e423
10 changed files with 363 additions and 22 deletions
@@ -43,6 +43,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// Two events (TaskUpdated + WorktreeUpdated) drive the same delta refresh, so two reads for
// one task can be in flight at once. Only the newest may write to the row.
private readonly Dictionary<string, long> _deltaSeq = new();
// Number/Title/Status for a predecessor id that isn't loaded into Items (a different list, My
// Day, or the completed group) -- populated by RefreshExternalDependencyCacheAsync so
// ApplyChainGrouping/ApplyDependencyWaitState can still render "after #N" and the blocked
// chip for a cross-list predecessor instead of silently dropping it. Refreshed, never evicted:
// stale entries just get overwritten on the next load/delta that references the same id.
private readonly Dictionary<string, (int Number, string Title, TaskStatus Status)> _externalDependencyInfo = new();
private long _deltaCounter;
// Phase 3 reconcile tick — see the block at the end of this class.
private readonly System.Timers.Timer _reconcileTimer = new(4_000);
@@ -363,6 +369,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
r.ParentTaskId == parent.Id && (r.IsQueued || r.IsWaiting));
}
// entity is not null here whenever `existing` is still reachable (see the branching
// above: an early return covers every entity-null-but-existing case except delete, which
// needs no lookup refresh).
if (entity is not null && existing is not null)
await RefreshExternalDependencyCacheAsync(db, DependencyLookupIds([existing]));
Regroup();
UpdateSubtitle();
}
@@ -548,6 +560,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
r.ParentFinalized = !string.IsNullOrEmpty(r.ParentTaskId)
&& finalizedParents.Contains(r.ParentTaskId!);
await RefreshExternalDependencyCacheAsync(db, DependencyLookupIds(Items), ct);
Regroup();
UpdateSubtitle();
ok = true;
@@ -556,6 +570,32 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
finally { OperationTiming.Shared.Record("db", "TasksIsland.LoadForListAsync", sw.Elapsed, ok); }
}
// Every predecessor id a row in `items` could need labelled/checked: its task_dependencies
// edges plus the planning chain's BlockedByTaskId (both resolved the same way in
// ApplyDependencyWaitState/BuildAfterLabel).
private static IEnumerable<string> DependencyLookupIds(IEnumerable<TaskRowViewModel> items) =>
items.SelectMany(r => string.IsNullOrEmpty(r.BlockedByTaskId)
? r.DependsOnTaskIds
: r.DependsOnTaskIds.Append(r.BlockedByTaskId));
// Resolves predecessor ids that aren't loaded into Items (a different list, My Day, or the
// completed group) so cross-list dependency display doesn't silently drop them. Cheap: reuses
// the caller's already-open context, one query for however many ids are actually missing.
private async Task RefreshExternalDependencyCacheAsync(
ClaudeDoDbContext db, IEnumerable<string> ids, CancellationToken ct = default)
{
var known = Items.Select(r => r.Id).ToHashSet();
var missing = ids.Where(id => !string.IsNullOrEmpty(id) && !known.Contains(id)).Distinct().ToList();
if (missing.Count == 0) return;
var rows = await db.Tasks
.Where(t => missing.Contains(t.Id))
.Select(t => new { t.Id, t.Number, t.Title, t.Status })
.ToListAsync(ct);
foreach (var r in rows)
_externalDependencyInfo[r.Id] = (r.Number, r.Title, r.Status);
}
internal void Regroup()
{
// Collapse parents that have children by default, so subtasks stay tucked away until
@@ -691,13 +731,66 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// 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);
// Blocked-by-dependency is independent of section (a blocked row can be Overdue or Open
// alike) and independent of chain membership, so it's computed once over all of Items
// rather than per section.
foreach (var r in Items)
ApplyDependencyWaitState(r, rowsById, _externalDependencyInfo);
overdue = ApplyChainGrouping(overdue, rowsById, _externalDependencyInfo);
open = ApplyChainGrouping(open, rowsById, _externalDependencyInfo);
completed = ApplyChainGrouping(completed, rowsById, _externalDependencyInfo);
return (overdue, open, completed);
}
// Assigns IsBlockedByDependency/DependencyWaitLabel for one row. BlockedByTaskId (the planning
// chain's single link) wins the label the same way a planning child's parent indent wins over
// chain-rail membership -- "parent wins" is the existing precedent (ApplyChainGrouping below).
private static void ApplyDependencyWaitState(
TaskRowViewModel row, Dictionary<string, TaskRowViewModel> rowsById,
IReadOnlyDictionary<string, (int Number, string Title, TaskStatus Status)> external)
{
if (row.Status != TaskStatus.Queued)
{
row.IsBlockedByDependency = false;
row.DependencyWaitLabel = null;
return;
}
var unmet = row.DependsOnTaskIds.Where(id => IsUnmet(id, rowsById, external)).ToList();
row.IsBlockedByDependency = unmet.Count > 0;
var waitIds = !string.IsNullOrEmpty(row.BlockedByTaskId) ? [row.BlockedByTaskId] : unmet;
row.DependencyWaitLabel = waitIds.Count == 0 ? null
: string.Join(", ", waitIds.Select(id => ResolveLabel(id, rowsById, external)).Where(s => s is not null));
}
// A predecessor missing from both rowsById and the external cache counts as unmet -- mirrors
// QueuePicker/Worker's TaskBlocking.Unmet: an edge pointing at an unresolved/vanished task
// keeps blocking rather than being silently treated as satisfied.
private static bool IsUnmet(
string id, Dictionary<string, TaskRowViewModel> rowsById,
IReadOnlyDictionary<string, (int Number, string Title, TaskStatus Status)> external)
{
if (rowsById.TryGetValue(id, out var p)) return p.Status != TaskStatus.Done;
if (external.TryGetValue(id, out var e)) return e.Status != TaskStatus.Done;
return true;
}
// Resolves a predecessor id to its display label, checking the in-view rows first and falling
// back to the cross-list cache (RefreshExternalDependencyCacheAsync) -- null only when the
// predecessor is genuinely unresolvable anywhere (e.g. a deleted task).
private static string? ResolveLabel(
string id, Dictionary<string, TaskRowViewModel> rowsById,
IReadOnlyDictionary<string, (int Number, string Title, TaskStatus Status)> external)
{
if (rowsById.TryGetValue(id, out var p)) return p.Number > 0 ? $"#{p.Number}" : p.Title;
if (external.TryGetValue(id, out var e)) return e.Number > 0 ? $"#{e.Number}" : e.Title;
return null;
}
// 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;
@@ -724,14 +817,15 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// 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. Names every
// predecessor it can resolve: a task can wait on n of them, and hiding all but the first
// would misreport what the row is actually waiting for.
private static string? BuildAfterLabel(TaskRowViewModel row, Dictionary<string, TaskRowViewModel> rowsById)
// predecessor it can resolve (in-view first, then the cross-list external cache): a task can
// wait on n of them, and hiding all but the first would misreport what the row is waiting for.
private static string? BuildAfterLabel(
TaskRowViewModel row, Dictionary<string, TaskRowViewModel> rowsById,
IReadOnlyDictionary<string, (int Number, string Title, TaskStatus Status)> external)
{
var parts = row.DependsOnTaskIds
.Select(id => rowsById.TryGetValue(id, out var p) ? p : null)
.Select(id => ResolveLabel(id, rowsById, external))
.Where(p => p is not null)
.Select(p => p!.Number > 0 ? $"#{p.Number}" : p.Title)
.ToList();
return parts.Count == 0 ? null : string.Join(", ", parts);
}
@@ -741,7 +835,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// (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<TaskRowViewModel> ApplyChainGrouping(
List<TaskRowViewModel> section, Dictionary<string, TaskRowViewModel> rowsById)
List<TaskRowViewModel> section, Dictionary<string, TaskRowViewModel> rowsById,
IReadOnlyDictionary<string, (int Number, string Title, TaskStatus Status)> external)
{
var sectionIds = section.Select(r => r.Id).ToHashSet();
var headIdOf = new Dictionary<TaskRowViewModel, string>();
@@ -763,7 +858,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
{
r.ShowAsChainMember = false;
r.ChainStep = null;
r.ChainAfterLabel = BuildAfterLabel(r, rowsById);
r.ChainAfterLabel = BuildAfterLabel(r, rowsById, external);
continue;
}
@@ -773,7 +868,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
{
r.ShowAsChainMember = false;
r.ChainStep = null;
r.ChainAfterLabel = BuildAfterLabel(r, rowsById);
r.ChainAfterLabel = BuildAfterLabel(r, rowsById, external);
continue;
}
@@ -790,7 +885,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
{
r.ShowAsChainMember = false;
r.ChainStep = null;
r.ChainAfterLabel = BuildAfterLabel(r, rowsById);
r.ChainAfterLabel = BuildAfterLabel(r, rowsById, external);
}
}
@@ -1739,7 +1834,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// reflects any of that after a Regroup — without this the tick would heal the row's data
// while leaving a completed task sitting in the Open section under a stale count. Gated
// on a real change so an idle tick stays free.
if (groupingChanged) Regroup();
if (groupingChanged)
{
await using var db2 = await _dbFactory.CreateDbContextAsync(ct);
await RefreshExternalDependencyCacheAsync(db2, DependencyLookupIds(Items), ct);
Regroup();
}
}
// The slice of a row that Regroup reads: which section it lands in, where it sits inside it,