feat(claude-do): merge feat(ui): depends_on-Blockierung sichtbar machen (Status-Chi

ClaudeDo-Task: 701de3ab-c326-4152-9488-8ac21fa21ffc
This commit is contained in:
mika kuns
2026-09-10 13:32:48 +02:00
10 changed files with 363 additions and 22 deletions
+3 -1
View File
@@ -239,6 +239,7 @@
"toggleSubtasksTip": "Unteraufgaben ein-/ausklappen",
"agentSuggestedTip": "Vom Agenten vorgeschlagen",
"interactiveChipTip": "Interaktive Sitzung offen — klicken, um im Mission Control dorthin zu springen",
"waitingOnTip": "Wartet auf {0}",
"scheduleTitle": "Aufgabe planen",
"scheduleWhen": "WANN",
"scheduleConfirm": "Planen",
@@ -277,6 +278,7 @@
"starTip": "Favorit",
"agentSettingsTip": "Agent-Einstellungen",
"agentSettingsHeading": "Agent-Einstellungen (Überschreibungen)",
"waitingOnPrefix": "Wartet auf",
"copyFormattedTip": "Titel, Beschreibung und offene Schritte kopieren",
"toggleEditPreviewTip": "Bearbeiten/Vorschau umschalten",
"planDay": "Tag planen",
@@ -775,7 +777,7 @@
"shell": { "restartingWorker": "Worker wird neu gestartet…", "unexpectedError": "Unerwarteter Fehler: {0}" },
"mainWindow": { "closeConfirm": { "message": "{0} Mission-Control-Sitzung(en) laufen noch. Beim Beenden werden sie abgebrochen. Trotzdem schließen?" } },
"agentStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "review": "Prüfung", "children": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen" },
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt", "interactive": "Interaktiv" },
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt", "interactive": "Interaktiv", "waiting": "Wartet" },
"failureReason": { "maxTurns": "Turn-Limit erreicht", "timeout": "Zeitüberschreitung", "usageLimit": "Nutzungslimit erreicht", "error": "Fehler", "cancelled": "Abgebrochen", "unknown": "Grund unbekannt" },
"failureReasonTooltip": { "maxTurns": "Turn-Limit erreicht ({0}/{1} Turns) — der Worktree ist meist brauchbar; Task fortsetzen statt zurücksetzen." },
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
+3 -1
View File
@@ -239,6 +239,7 @@
"toggleSubtasksTip": "Expand / collapse subtasks",
"agentSuggestedTip": "Suggested by the agent",
"interactiveChipTip": "Interactive session open — click to jump to it in Mission Control",
"waitingOnTip": "Waiting on {0}",
"scheduleTitle": "Schedule task",
"scheduleWhen": "WHEN",
"scheduleConfirm": "Schedule",
@@ -277,6 +278,7 @@
"starTip": "Star",
"agentSettingsTip": "Agent settings",
"agentSettingsHeading": "Agent settings (overrides)",
"waitingOnPrefix": "Waiting on",
"copyFormattedTip": "Copy title, description and open steps",
"toggleEditPreviewTip": "Toggle edit/preview",
"planDay": "Plan day",
@@ -775,7 +777,7 @@
"shell": { "restartingWorker": "Restarting worker…", "unexpectedError": "Unexpected error: {0}" },
"mainWindow": { "closeConfirm": { "message": "{0} Mission Control session(s) are still running. Closing the app will end them. Close anyway?" } },
"agentStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "review": "Review", "children": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" },
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked", "interactive": "Interactive" },
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked", "interactive": "Interactive", "waiting": "Waiting" },
"failureReason": { "maxTurns": "Turn limit reached", "timeout": "Timed out", "usageLimit": "Usage limit reached", "error": "Error", "cancelled": "Cancelled", "unknown": "Unknown reason" },
"failureReasonTooltip": { "maxTurns": "Turn limit reached ({0}/{1} turns) — the worktree is usually fine; continue the task instead of resetting it." },
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
+12
View File
@@ -297,6 +297,18 @@
<Setter Property="Foreground" Value="{StaticResource StatusQueuedBrush}" />
</Style>
<!-- waiting → muted amber: a Queued task blocked on an unmet predecessor (a task_dependencies
edge, or the planning chain's BlockedByTaskId) — distinct from queued (unblocked, next up
to run) and from children (a parent waiting on its own subtasks). Mutually exclusive with
.queued on the same chip (IsQueued/IsWaiting), so no declaration-order fight between them. -->
<Style Selector="Border.chip.waiting">
<Setter Property="Background" Value="#332F1A" />
<Setter Property="BorderBrush" Value="#4D451A" />
</Style>
<Style Selector="Border.chip.waiting > TextBlock">
<Setter Property="Foreground" Value="#C9A227" />
</Style>
<!-- done → green (#6FA86B) -->
<Style Selector="Border.chip.done">
<Setter Property="Background" Value="{StaticResource DoneTintBrush}" />
@@ -112,11 +112,31 @@ public sealed partial class TaskRowViewModel : ViewModelBase
[NotifyPropertyChangedFor(nameof(IsQueued))]
[NotifyPropertyChangedFor(nameof(IsWaiting))]
[NotifyPropertyChangedFor(nameof(StatusChipClass))]
[NotifyPropertyChangedFor(nameof(StatusLabel))]
[NotifyPropertyChangedFor(nameof(StatusChipTooltip))]
[NotifyPropertyChangedFor(nameof(CanRemoveFromQueue))]
private string? _blockedByTaskId;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(DependsOnTaskId))]
private IReadOnlyList<string> _dependsOnTaskIds = [];
// Extrinsic — assigned by TasksIslandViewModel.ApplyDependencyWaitState, which alone has the
// cross-row graph (plus any cross-list predecessor lookup) needed to tell an unmet
// task_dependencies edge from a satisfied one. True only for the task_dependencies case;
// IsWaiting also ORs in the older BlockedByTaskId planning-chain link.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsQueued))]
[NotifyPropertyChangedFor(nameof(IsWaiting))]
[NotifyPropertyChangedFor(nameof(StatusChipClass))]
[NotifyPropertyChangedFor(nameof(StatusLabel))]
[NotifyPropertyChangedFor(nameof(StatusChipTooltip))]
[NotifyPropertyChangedFor(nameof(CanRemoveFromQueue))]
private bool _isBlockedByDependency;
// The predecessor(s) this row is waiting on, rendered ("#42" or "#42, #57"), or null when not
// waiting. Prefers BlockedByTaskId's single predecessor (the planning chain "parent wins" the
// same way it does for the chain rail), else lists every unmet task_dependencies edge.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(StatusChipTooltip))]
private string? _dependencyWaitLabel;
// The single predecessor, or null when the task has none or several. The chain rail can only
// draw a line, so a task waiting on n predecessors gets a ChainAfterLabel instead — see
@@ -275,8 +295,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
// task). A live ConPTY session is that same shape but not parked — it reads "Interactive".
public bool IsParked => Status == TaskStatus.Idle && WorktreeState == ClaudeDo.Data.Models.WorktreeState.Active
&& !HasInteractiveSession;
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId) && !IsBlockedByDependency;
public bool IsWaiting => Status == TaskStatus.Queued && (!string.IsNullOrEmpty(BlockedByTaskId) || IsBlockedByDependency);
public bool CanRemoveFromQueue => IsQueued || IsWaiting || HasQueuedSubtasks;
// "Send to queue" is the single queue entry. On a finalized planning parent it queues the
// plan (children) via CanQueuePlan; an Active (not-yet-finalized) planning parent is hidden —
@@ -412,9 +432,12 @@ public sealed partial class TaskRowViewModel : ViewModelBase
: FailureReasonLabel;
// Drives the status chip tooltip: an open interactive session takes priority (it's tappable),
// otherwise a Failed task's reason, otherwise no tooltip.
// then the predecessor(s) a waiting task is blocked on, then a Failed task's reason,
// otherwise no tooltip.
public string? StatusChipTooltip
=> HasInteractiveSession ? Loc.T("tasks.interactiveChipTip") : FailureReasonTooltip;
=> HasInteractiveSession ? Loc.T("tasks.interactiveChipTip")
: IsWaiting && DependencyWaitLabel is not null ? Loc.T("tasks.waitingOnTip", DependencyWaitLabel)
: FailureReasonTooltip;
public string DiffAdditionsText => $"+{DiffAdditions}";
public string DiffDeletionsText => $"−{DiffDeletions}";
@@ -422,7 +445,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public string StatusLabel
=> HasInteractiveSession ? Loc.T("vm.taskStatus.interactive")
: IsParked ? Loc.T("vm.taskStatus.parked") : Status switch
: IsParked ? Loc.T("vm.taskStatus.parked")
: IsWaiting ? Loc.T("vm.taskStatus.waiting") : Status switch
{
TaskStatus.Idle => Loc.T("vm.taskStatus.idle"),
TaskStatus.Queued => Loc.T("vm.taskStatus.queued"),
@@ -444,7 +468,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public string StatusChipClass => HasInteractiveSession
? "interactive"
: (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId)) switch
: (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId) || IsBlockedByDependency) switch
{
(TaskStatus.Running, _) => "running",
(TaskStatus.WaitingForReview, _) => "review",
@@ -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,
@@ -41,6 +41,14 @@
</Viewbox>
<TextBlock Classes="meta" Text="{Binding Task.Branch}"/>
</StackPanel>
<!-- Dependency-wait line: this task is Queued but blocked on an unmet predecessor (a
task_dependencies edge, or the planning chain's BlockedByTaskId). -->
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,4,0,0"
IsVisible="{Binding Task.IsWaiting, FallbackValue=False}">
<TextBlock Classes="meta" Text="{loc:Tr details.waitingOnPrefix}"/>
<TextBlock Classes="meta" Text="{Binding Task.DependencyWaitLabel}"/>
</StackPanel>
</StackPanel>
<!-- Column 1: trash button (not running) -->
@@ -142,7 +142,8 @@
Classes.children="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=WaitingForChildren}"
Classes.done="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Done}"
Classes.error="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Failed}"
Classes.queued="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Queued}">
Classes.queued="{Binding IsQueued}"
Classes.waiting="{Binding IsWaiting}">
<TextBlock Text="{Binding StatusLabel}"/>
</Border>
@@ -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);
}
}
@@ -281,10 +281,41 @@ public class TaskRowViewModelNotificationsTests
nameof(TaskRowViewModel.IsQueued),
nameof(TaskRowViewModel.IsWaiting),
nameof(TaskRowViewModel.StatusChipClass),
nameof(TaskRowViewModel.StatusLabel),
nameof(TaskRowViewModel.StatusChipTooltip),
nameof(TaskRowViewModel.CanRemoveFromQueue),
}, raised);
}
[Fact]
public void IsBlockedByDependencyChanged_Notifies_Dependents()
{
var vm = new TaskRowViewModel { Id = "t" };
var raised = Raised(vm, () => vm.IsBlockedByDependency = true);
Assert.Equal(new HashSet<string?>
{
nameof(TaskRowViewModel.IsBlockedByDependency),
nameof(TaskRowViewModel.IsQueued),
nameof(TaskRowViewModel.IsWaiting),
nameof(TaskRowViewModel.StatusChipClass),
nameof(TaskRowViewModel.StatusLabel),
nameof(TaskRowViewModel.StatusChipTooltip),
nameof(TaskRowViewModel.CanRemoveFromQueue),
}, raised);
}
[Fact]
public void DependencyWaitLabelChanged_Notifies_Dependents()
{
var vm = new TaskRowViewModel { Id = "t" };
var raised = Raised(vm, () => vm.DependencyWaitLabel = "#42");
Assert.Equal(new HashSet<string?>
{
nameof(TaskRowViewModel.DependencyWaitLabel),
nameof(TaskRowViewModel.StatusChipTooltip),
}, raised);
}
[Fact]
public void HasPlanningChildrenChanged_Notifies_Dependents()
{