Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
T
mika kuns 589b9e75f3 fix(ui): unfreeze the operation spinner and attribute rebind churn
Three causes behind the stuck indicator:
- OperationStatus.End did not retire the generation, so tick work posted
  while the dispatcher was blocked drained afterwards and set
  ShowIndicator back to true on a finished operation.
- The startup update check ran in Task.Run; OperationStatus writes its
  observable properties on the calling thread, so the final
  ShowIndicator=false never reached the binding.
- OperationIndicator overwrote its own DataContext from the Status
  property, which re-targeted the call-site binding. It now scopes the
  DataContext to an inner panel and collapses itself when Status is
  null; every call site switched from DataContext= to Status=.

Also gates the footer connection pill in the command body instead of
CanExecute (a disabled command greyed out the ONLINE chip), and extends
OperationTiming: pid per line, 4 MB rollover, fast successes dropped
(failures always kept), and DetailsIsland.BindAsync now carries the
selection trigger via TasksIslandViewModel.SelectFrom.
2026-08-12 13:50:05 +02:00

1583 lines
67 KiB
C#

using System.Collections.ObjectModel;
using System.Globalization;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
using ClaudeDo.Data.Filtering;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels.Islands;
/// <summary>A group header entry in <see cref="TasksIslandViewModel.Rows"/> — the flat list's
/// other union member alongside <see cref="TaskRowViewModel"/>. Instances are persistent (one per
/// section, mutated in place by <see cref="TasksIslandViewModel.Regroup"/>) so an unchanged header
/// keeps its identity across a reconcile.</summary>
public sealed partial class HeaderRow : ViewModelBase
{
[ObservableProperty] private string _label = "";
[ObservableProperty] private int _count;
public bool IsOverdue { get; init; }
public IRelayCommand? ActionCommand { get; init; }
public bool HasAction => ActionCommand is not null;
}
public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IWorkerClient? _worker;
private readonly Dictionary<string, bool> _expandedState = new();
private ListNavItemViewModel? _currentList;
private CancellationTokenSource? _loadCts;
// Task ids with a live ConPTY pane in Mission Control; kept here so rows loaded later still
// pick the flag up (see SyncInteractiveSessions).
private readonly HashSet<string> _interactiveSessionIds = new();
private static readonly TaskListFilterRegistry _filters = new();
// 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();
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);
public event EventHandler? SelectionChanged;
/// <summary>Why the selection last changed. Recorded with the detail-pane bind, so the timing
/// log attributes rebind churn to a trigger instead of leaving an anonymous count.</summary>
public string SelectionSource { get; private set; } = "init";
/// <summary>Sets <see cref="SelectedTask"/> and tags what triggered it — use this instead of
/// assigning the property, otherwise the bind is logged as "?" .</summary>
public void SelectFrom(TaskRowViewModel? row, string source)
{
SelectionSource = source;
SelectedTask = row;
}
public event EventHandler? FocusAddTaskRequested;
public event EventHandler? TasksChanged;
public event Action? NotesRequested;
public event Action? PrepRequested;
public event Action<string>? ErrorReported;
public void RequestFocusAddTask() => FocusAddTaskRequested?.Invoke(this, EventArgs.Empty);
[RelayCommand]
private void OpenNotes()
{
SelectFrom(null, "notes");
NotesRequested?.Invoke();
}
[RelayCommand]
private void ShowPrepLog() => PrepRequested?.Invoke();
[RelayCommand]
private async Task ClearDayAsync()
{
if (_worker is null) return;
try { await _worker.ClearMyDayAsync(); }
catch { /* worker offline; broadcast will reconcile on return */ }
}
public ObservableCollection<TaskRowViewModel> Items { get; } = new();
/// <summary>Flat, virtualization-friendly view of <see cref="Items"/>: group headers
/// (<see cref="HeaderRow"/>) interleaved with visible <see cref="TaskRowViewModel"/> rows, in
/// display order. Reconciled granularly by <see cref="Regroup"/> — never Clear+Add.</summary>
public ObservableCollection<object> Rows { get; } = new();
private readonly HeaderRow _overdueHeaderRow = new() { IsOverdue = true };
private readonly HeaderRow _openHeaderRow = new();
private readonly HeaderRow _completedHeaderRow;
[ObservableProperty] private string _newTaskTitle = "";
[ObservableProperty] private TaskRowViewModel? _selectedTask;
[ObservableProperty] private string _headerTitle = "";
[ObservableProperty] private string _headerEyebrow = "";
[ObservableProperty] private string _subtitle = "";
[ObservableProperty] private string _statusPill = "";
[ObservableProperty] private bool _hasStatusPill;
[ObservableProperty] private bool _isShowingCompleted = true;
[ObservableProperty] private bool _hasOverdue;
[ObservableProperty] private bool _hasOpen;
[ObservableProperty] private bool _hasCompleted;
[ObservableProperty] private bool _showOpenLabel;
[ObservableProperty] private string _completedHeader = "";
[ObservableProperty] private bool _showNotesRow;
[ObservableProperty] private bool _isMyDayList;
[ObservableProperty] private bool _isLetClaudeVisible;
[ObservableProperty] private bool _isQuickClaudeVisible;
// Shared by QueuePlanningSubtasksAsync and FinalizePlanningSessionAsync — both are triggered
// from a context menu that closes the instant a click lands, so there is no per-row surface
// left standing to anchor an indicator to. The island header is the nearest surface still
// visible after the menu closes.
public OperationStatus PlanningOperation { get; } = new();
public event EventHandler? LetClaudeHandleRequested;
[RelayCommand]
private void LetClaudeHandle() => LetClaudeHandleRequested?.Invoke(this, EventArgs.Empty);
// Opens a task-less ConPTY session directly in the current list's working dir. The shell owns
// Mission Control, so this just raises an event for it to act on (mirrors OpenConPtySessionRequested).
public event Action<string>? OpenQuickClaudeSessionRequested;
[RelayCommand]
private void OpenQuickClaudeSession()
{
var dir = _currentList?.WorkingDir;
if (string.IsNullOrWhiteSpace(dir))
{
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.quickClaudeNoWorkingDir"));
return;
}
if (!System.IO.Directory.Exists(dir))
{
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.quickClaudeDirMissing", dir));
return;
}
OpenQuickClaudeSessionRequested?.Invoke(dir);
}
internal Task? LoadTask { get; private set; }
public Func<UnfinishedPlanningModalViewModel, Task>? ShowUnfinishedPlanningModal { get; set; }
private readonly EventHandler _langChangedHandler;
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient? worker = null)
{
_dbFactory = dbFactory;
_worker = worker;
_completedHeaderRow = new() { ActionCommand = ClearCompletedCommand };
CompletedHeader = Loc.T("vm.tasksIsland.completedHeader");
if (_worker is not null)
{
_worker.TaskUpdatedEvent += OnWorkerTaskUpdated;
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
_worker.ListUpdatedEvent += OnWorkerListUpdated;
_worker.ConnectionRestoredEvent += () => LoadForList(_currentList);
_worker.RefineStartedEvent += OnRefineStarted;
_worker.RefineFinishedEvent += OnRefineFinished;
}
_langChangedHandler = (_, _) => RefreshLocalizedText();
Loc.LanguageChanged += _langChangedHandler;
_reconcileTimer.Elapsed += (_, _) => Dispatcher.UIThread.Post(() => _ = ReconcileTickAsync());
_reconcileTimer.Start();
// OperationStatus is a nested ObservableObject — [NotifyCanExecuteChangedFor] can't see
// into it, so IsRunning changes are relayed by hand.
PlanningOperation.PropertyChanged += (_, e) =>
{
if (e.PropertyName != nameof(OperationStatus.IsRunning)) return;
QueuePlanningSubtasksCommand.NotifyCanExecuteChanged();
FinalizePlanningSessionCommand.NotifyCanExecuteChanged();
};
}
public void Dispose()
{
Loc.LanguageChanged -= _langChangedHandler;
_reconcileTimer.Stop();
_reconcileTimer.Dispose();
}
private void RefreshLocalizedText()
{
CompletedHeader = Loc.T("vm.tasksIsland.completedHeader");
foreach (var row in Items) row.RefreshLocalized();
// The section headers are now Rows entries whose Label is a resolved string, not a live
// {loc:Tr} binding in the view — without re-emitting them they keep the old language.
Regroup();
}
private async void OnWorkerListUpdated(string listId)
{
// Mirror the renamed list onto every task row that references it,
// so the per-row ListName chip on virtual lists stays current.
try
{
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == listId);
if (entity is null) return;
var visibleIds = Items.Select(r => r.Id).ToHashSet();
if (visibleIds.Count == 0) return;
var matchingIds = await db.Tasks.AsNoTracking()
.Where(t => t.ListId == listId && visibleIds.Contains(t.Id))
.Select(t => t.Id)
.ToListAsync();
var matching = matchingIds.ToHashSet();
foreach (var row in Items)
if (matching.Contains(row.Id) && row.ListName != entity.Name)
row.ListName = entity.Name;
}
catch { }
}
private async void OnWorkerTaskUpdated(string taskId)
=> await RefreshTaskFromWorkerAsync(taskId);
// Awaitable so tests can drive it deterministically. One retry, then a full reload:
// a swallowed exception here used to leave the row on a stale status permanently.
internal async Task RefreshTaskFromWorkerAsync(string taskId)
{
var list = _currentList;
if (list is null) return;
// virtual:queued / virtual:running include Planning parents whose children match,
// which can't be decided from a single entity. Always full-reload in those cases.
if (list.Kind == ListKind.Virtual &&
(list.Id == "virtual:queued" || list.Id == "virtual:running"))
{
LoadForList(list);
return;
}
var seq = ++_deltaCounter;
_deltaSeq[taskId] = seq;
try
{
await ApplyDeltaAsync(taskId, list, seq);
}
catch (Exception first)
{
System.Diagnostics.Debug.WriteLine(
$"TasksIsland: delta refresh for {taskId} failed ({first.Message}); retrying");
try
{
await ApplyDeltaAsync(taskId, list, seq);
}
catch (Exception second)
{
System.Diagnostics.Debug.WriteLine(
$"TasksIsland: delta retry for {taskId} failed ({second.Message}); full reload");
LoadForList(list);
}
}
}
private async Task ApplyDeltaAsync(string taskId, ListNavItemViewModel list, long seq)
{
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks
.Include(t => t.List)
.Include(t => t.Worktree)
.FirstOrDefaultAsync(t => t.Id == taskId);
// A newer refresh for this task started while we were reading — its result is fresher.
if (_deltaSeq.TryGetValue(taskId, out var current) && current != seq) return;
// A parent transition (finalize/discard) broadcasts only the parent's id, but it
// changes its children's derived state — finalize flips them Draft→Planned, discard
// deletes them. The delta path below only touches the parent row and never recomputes
// the child-derived flags (ParentFinalized, HasPlanningChildren) nor drops deleted
// children, so reconcile the whole list when the updated task is (or owns) a subtree.
if (entity is not null &&
(entity.PlanningPhase != PlanningPhase.None || Items.Any(r => r.ParentTaskId == entity.Id)))
{
LoadForList(list);
return;
}
var existing = Items.FirstOrDefault(r => r.Id == taskId);
if (entity is null)
{
if (existing is not null) Items.Remove(existing);
}
else
{
var matches = TaskMatchesList(entity, list);
if (existing is not null && matches) existing.UpdateFromEntity(entity);
else if (existing is not null) Items.Remove(existing);
else if (matches) { LoadForList(list); return; }
else return;
}
// Keep the parent's HasQueuedSubtasks flag in sync when a child's status flips.
if (entity is not null && !string.IsNullOrEmpty(entity.ParentTaskId))
{
var parent = Items.FirstOrDefault(r => r.Id == entity.ParentTaskId);
if (parent is not null)
parent.HasQueuedSubtasks = Items.Any(r =>
r.ParentTaskId == parent.Id && (r.IsQueued || r.IsWaiting));
}
Regroup();
UpdateSubtitle();
}
// NOTE: virtual:queued/virtual:running cannot be decided by a single entity — a Planning
// parent matches iff any child has the matching status. OnWorkerTaskUpdated handles those
// lists via a full reload rather than the delta path.
private static bool TaskMatchesList(TaskEntity t, ListNavItemViewModel list) => list.Kind switch
{
ListKind.Smart when list.Id == "smart:my-day" => t.IsMyDay,
ListKind.Smart when list.Id == "smart:important" => t.IsStarred,
ListKind.Smart when list.Id == "smart:planned" => t.ScheduledFor != null,
ListKind.Virtual when list.Id == "virtual:review" => t.Status == TaskStatus.WaitingForReview,
ListKind.User => $"user:{t.ListId}" == list.Id,
_ => false,
};
private void OnCurrentListPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (sender is not ListNavItemViewModel vm) return;
if (e.PropertyName == nameof(ListNavItemViewModel.Name))
HeaderTitle = vm.Name;
else if (e.PropertyName == nameof(ListNavItemViewModel.WorkingDir))
{
IsLetClaudeVisible = vm.Kind == ListKind.User && !string.IsNullOrWhiteSpace(vm.WorkingDir);
IsQuickClaudeVisible = IsLetClaudeVisible;
}
}
public void LoadForList(ListNavItemViewModel? list)
{
_loadCts?.Cancel();
_loadCts?.Dispose();
_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);
// A same-list reload (triggered by OnWorkerTaskUpdated's full-reload branches) rebuilds
// every row from scratch. Reusing the previous instances by id — instead of handing back
// brand new ones — keeps SelectedTask (and the bound DetailsIslandViewModel.Task) pointed
// at a live row instead of an orphan that never receives another update.
var reusable = listChanged ? null : Items.ToDictionary(r => r.Id);
if (_currentList is not null)
_currentList.PropertyChanged -= OnCurrentListPropertyChanged;
_currentList = list;
if (_currentList is not null)
_currentList.PropertyChanged += OnCurrentListPropertyChanged;
Items.Clear();
Rows.Clear();
HasOverdue = false;
HasOpen = false;
HasCompleted = false;
ShowOpenLabel = false;
ShowNotesRow = false;
if (listChanged) SelectFrom(null, "list-change");
if (list is null) { IsLetClaudeVisible = false; IsQuickClaudeVisible = false; LoadTask = Task.CompletedTask; return; }
HeaderTitle = list.Name;
HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant();
ShowNotesRow = list.Id == "smart:my-day";
IsMyDayList = list.Id == "smart:my-day";
IsLetClaudeVisible = list.Kind == ListKind.User && !string.IsNullOrWhiteSpace(list.WorkingDir);
IsQuickClaudeVisible = IsLetClaudeVisible;
LoadTask = LoadForListAsync(list, ct, reusable);
}
private async Task LoadForListAsync(
ListNavItemViewModel list, CancellationToken ct, Dictionary<string, TaskRowViewModel>? reusable)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
await using var db = await _dbFactory.CreateDbContextAsync(ct);
var filter = _filters.Resolve(list.Id);
var baseQuery = db.Tasks.Include(t => t.List).Include(t => t.Worktree);
var filteredList = filter is null
? new List<TaskEntity>()
: await baseQuery.Where(filter.MatchExpression).ToListAsync(ct);
ct.ThrowIfCancellationRequested();
if (filter is not null)
{
// Contextual parent rows (e.g. a planning parent hosting an already-matched queued/running
// child): only fetch candidates that could plausibly qualify — parents of tasks we already
// matched — instead of scanning the whole table.
var candidateParentIds = filteredList
.Where(t => t.ParentTaskId != null)
.Select(t => t.ParentTaskId!)
.Distinct()
.ToList();
if (candidateParentIds.Count > 0)
{
var existingParentIds = filteredList.Select(t => t.Id).ToHashSet();
var parentCandidates = await baseQuery
.Where(t => candidateParentIds.Contains(t.Id) && !existingParentIds.Contains(t.Id))
.ToListAsync(ct);
foreach (var p in parentCandidates)
if (filter.MatchesAsContext(p, filteredList))
filteredList.Add(p);
}
}
ct.ThrowIfCancellationRequested();
// Pull in every child of an already-matched top-level row, regardless of whether the child
// itself matches the filter, so subtasks render nested under their parent.
var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet();
if (topIds.Count > 0)
{
var existingIds = filteredList.Select(t => t.Id).ToHashSet();
var extraChildren = await baseQuery
.Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId!) && !existingIds.Contains(t.Id))
.ToListAsync(ct);
filteredList.AddRange(extraChildren);
}
filteredList = filteredList.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt).ToList();
var showListChip = list.Kind == ListKind.Virtual;
foreach (var t in filteredList)
{
TaskRowViewModel row;
if (reusable is not null && reusable.TryGetValue(t.Id, out var existing))
{
row = existing;
row.UpdateFromEntity(t);
}
else
{
row = TaskRowViewModel.FromEntity(t);
}
row.ShowListChip = showListChip;
row.HasInteractiveSession = _interactiveSessionIds.Contains(row.Id);
Items.Add(row);
}
// Mark any top-level row that has at least one child as a planning parent,
// so its subtasks remain expandable even after the parent is queued/running.
var parentsWithChildren = Items
.Where(r => r.IsChild && !string.IsNullOrEmpty(r.ParentTaskId))
.Select(r => r.ParentTaskId!)
.ToHashSet();
foreach (var r in Items)
if (parentsWithChildren.Contains(r.Id))
r.HasPlanningChildren = true;
// Mark planning parents whose children are currently queued/waiting,
// so the dequeue affordance is visible on the parent row.
var parentsWithQueuedKids = Items
.Where(r => r.IsChild && !string.IsNullOrEmpty(r.ParentTaskId)
&& (r.IsQueued || r.IsWaiting))
.Select(r => r.ParentTaskId!)
.ToHashSet();
foreach (var r in Items)
r.HasQueuedSubtasks = parentsWithQueuedKids.Contains(r.Id);
// A subtask is "Planned" (queueable) once its planning parent is finalized;
// until then it is a "Draft".
var finalizedParents = Items
.Where(r => r.PlanningPhase == PlanningPhase.Finalized)
.Select(r => r.Id)
.ToHashSet();
foreach (var r in Items)
r.ParentFinalized = !string.IsNullOrEmpty(r.ParentTaskId)
&& finalizedParents.Contains(r.ParentTaskId!);
Regroup();
UpdateSubtitle();
ok = true;
}
catch (OperationCanceledException) { }
finally { OperationTiming.Shared.Record("db", "TasksIsland.LoadForListAsync", sw.Elapsed, ok); }
}
internal void Regroup()
{
// Collapse parents that have children by default, so subtasks stay tucked away until
// the user expands the row (an explicit toggle is saved and wins over this default).
var childrenByParent = Items
.Where(r => r.IsChild && !string.IsNullOrEmpty(r.ParentTaskId))
.GroupBy(r => r.ParentTaskId!)
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var parent in Items.Where(r => r.IsPlanningParent && !r.IsChild && !r.Done))
{
if (_expandedState.ContainsKey(parent.Id)) continue;
if (childrenByParent.TryGetValue(parent.Id, out var kids) && kids.Count > 0)
parent.IsExpanded = false;
}
// Restore IsExpanded from saved state
foreach (var r in Items)
{
if (_expandedState.TryGetValue(r.Id, out var saved))
r.IsExpanded = saved;
}
var (overdue, open, completed) = ClassifyItems();
HasOverdue = overdue.Count > 0;
HasOpen = open.Count > 0;
HasCompleted = completed.Count > 0;
ShowOpenLabel = HasOpen && HasOverdue;
CompletedHeader = Loc.T("vm.tasksIsland.completedHeaderCount", completed.Count);
// Build the flat Rows target: header + rows per section, omitting a section entirely
// when it has nothing to show (no header, no rows) instead of hiding it via IsVisible.
var target = new List<object>();
if (HasOverdue)
{
_overdueHeaderRow.Label = Loc.T("tasks.overdue");
_overdueHeaderRow.Count = overdue.Count;
target.Add(_overdueHeaderRow);
target.AddRange(overdue);
}
if (HasOpen)
{
if (ShowOpenLabel)
{
_openHeaderRow.Label = Loc.T("tasks.tasks");
_openHeaderRow.Count = open.Count;
target.Add(_openHeaderRow);
}
target.AddRange(open);
}
if (HasCompleted && IsShowingCompleted)
{
_completedHeaderRow.Label = CompletedHeader;
_completedHeaderRow.Count = completed.Count;
target.Add(_completedHeaderRow);
target.AddRange(completed);
}
// Reconcile Rows in place (granular Insert/Move/Remove) rather than Clear+Add, so toggling
// a parent only touches its own child rows — the ListBox keeps every unchanged container
// instead of tearing the whole list down on a Reset.
SyncCollection(Rows, target);
}
// Builds the hierarchy-aware flat ordering (top-level rows interleaved with visible children,
// orphans flagged so they render flat) and partitions it into Overdue/Open/Completed. Pure
// with respect to chain state (ShowAsChainMember/ChainStep/ChainAfterLabel) — callers that
// only need section membership/counts (e.g. ClearCompletedAsync) should use this directly
// rather than ClassifyItems, which additionally mutates every row via ApplyChainGrouping.
private (List<TaskRowViewModel> Overdue, List<TaskRowViewModel> Open, List<TaskRowViewModel> Completed) PartitionItems()
{
// Items is already ordered by SortOrder from the DB query.
// Treat rows whose ParentTaskId is not in the current view as orphans -> top-level.
var visibleIds = Items.Select(r => r.Id).ToHashSet();
// A child reads as a child only while its parent is in the view. Flag orphans so they
// render flat (no indent, no Draft/Planned badge) instead of breaking the layout.
foreach (var r in Items)
r.ParentInView = string.IsNullOrEmpty(r.ParentTaskId) || visibleIds.Contains(r.ParentTaskId!);
bool IsTopLevel(TaskRowViewModel r) =>
!r.IsChild
|| string.IsNullOrEmpty(r.ParentTaskId)
|| !visibleIds.Contains(r.ParentTaskId!);
var topLevel = Items.Where(IsTopLevel);
var flat = new List<TaskRowViewModel>();
var emitted = new HashSet<string>();
foreach (var parent in topLevel)
{
if (!emitted.Add(parent.Id)) continue;
flat.Add(parent);
// Also expand for Done parents so their (Done) children reach the classification
// loop and land alongside the parent in Completed.
if ((parent.IsPlanningParent || parent.Done) && parent.IsExpanded)
{
var children = Items.Where(r => r.ParentTaskId == parent.Id);
foreach (var c in children)
if (emitted.Add(c.Id))
flat.Add(c);
}
}
var today = DateTime.Today;
var overdue = new List<TaskRowViewModel>();
var open = new List<TaskRowViewModel>();
var completed = new List<TaskRowViewModel>();
foreach (var r in flat)
{
var underOpenPlanningParent = r.IsChild &&
flat.Any(p => p.Id == r.ParentTaskId && p.IsPlanningParent && !p.Done);
if (r.Done && !underOpenPlanningParent)
completed.Add(r);
else if (r.ScheduledFor is { } d && d.Date < today)
overdue.Add(r);
else
open.Add(r);
}
return (overdue, open, completed);
}
// ClassifyItems layers the chain-grouping mutation on top of PartitionItems, exactly as
// Regroup renders it — every row in the result has ShowAsChainMember/ChainStep/ChainAfterLabel
// written. Only call this from a path that actually renders the result; a caller that just
// needs section membership (e.g. a count) should call PartitionItems instead.
private (List<TaskRowViewModel> Overdue, List<TaskRowViewModel> Open, List<TaskRowViewModel> Completed) ClassifyItems()
{
var (overdue, open, completed) = PartitionItems();
// 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<string, TaskRowViewModel> rowsById)
{
var current = row;
var step = 0;
var visited = new HashSet<string> { 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<string, TaskRowViewModel> 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<TaskRowViewModel> ApplyChainGrouping(
List<TaskRowViewModel> section, Dictionary<string, TaskRowViewModel> rowsById)
{
var sectionIds = section.Select(r => r.Id).ToHashSet();
var headIdOf = new Dictionary<TaskRowViewModel, string>();
var stepOf = new Dictionary<TaskRowViewModel, int>();
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<TaskRowViewModel>(section.Count);
var consumed = new HashSet<string>();
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;
var open = Items.Count(i => !i.Done);
var running = Items.Count(i => i.Status == TaskStatus.Running);
var review = Items.Count(i => i.Status == TaskStatus.Done && i.Branch != null);
Subtitle = open == 1 ? "1 open task" : $"{open} open tasks";
if (running > 0 || review > 0)
{
StatusPill = $"{running} running · {review} review";
HasStatusPill = true;
}
else
{
StatusPill = "";
HasStatusPill = false;
}
}
[RelayCommand]
private async Task AddAsync()
{
if (string.IsNullOrWhiteSpace(NewTaskTitle) || _currentList?.Kind != ListKind.User) return;
var listId = _currentList.Id["user:".Length..];
await using var db = await _dbFactory.CreateDbContextAsync();
// A manual list holds reminders, so tasks created in it start out manual.
var listIsManual = await db.Lists.Where(l => l.Id == listId).Select(l => l.IsManual).FirstOrDefaultAsync();
var entity = new TaskEntity
{
Id = Guid.NewGuid().ToString("N"),
ListId = listId,
Title = NewTaskTitle.Trim(),
Status = TaskStatus.Idle,
CreatedAt = DateTime.UtcNow,
IsManual = listIsManual,
};
await new TaskRepository(db).AddAsync(entity);
var row = TaskRowViewModel.FromEntity(entity);
row.ShowListChip = _currentList?.Kind == ListKind.Virtual;
Items.Add(row);
Regroup();
SelectFrom(row, "new-task");
NewTaskTitle = "";
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
/// <summary>Replaces the set of tasks that currently have an open interactive (ConPTY) session,
/// so their lifecycle chip reads "Interactive" instead of "Parked".</summary>
public void SyncInteractiveSessions(IEnumerable<string> taskIds)
{
_interactiveSessionIds.Clear();
foreach (var id in taskIds)
_interactiveSessionIds.Add(id);
foreach (var r in Items)
r.HasInteractiveSession = _interactiveSessionIds.Contains(r.Id);
}
public bool CanReorder => _currentList?.Kind == ListKind.User;
public string? CurrentListId => _currentList?.Id;
/// <summary>Set by the shell (mirrors <see cref="ListsIslandViewModel.Dialogs"/>) so a
/// cross-repo move can confirm via the shared modal seam.</summary>
public IDialogService? Dialogs { get; set; }
public void ClearDropHints()
{
foreach (var r in Items)
{
r.DropHintAbove = false;
r.DropHintBelow = false;
}
}
public void SetDropHint(TaskRowViewModel target, bool placeBelow)
{
foreach (var r in Items)
{
var isTarget = ReferenceEquals(r, target);
r.DropHintAbove = isTarget && !placeBelow;
r.DropHintBelow = isTarget && placeBelow;
}
}
public async Task ReorderAsync(TaskRowViewModel source, TaskRowViewModel target, bool placeBelow)
{
if (!CanReorder || _currentList is null) return;
if (source.IsRunning || target.IsRunning) return;
if (ReferenceEquals(source, target)) return;
// Master Items: single Move event (no Reset) so ItemsControls animate, not rebuild.
MoveWithinCollection(Items, source, target, placeBelow);
// Apply the same move in Rows, but only when both rows live in the same section.
// Reorder never changes which section (Open/Overdue/Completed) a row belongs to —
// that's determined by Done flag and ScheduledFor date, not drag-drop.
var sourceSection = SectionFor(source);
var targetSection = SectionFor(target);
if (sourceSection is not null && ReferenceEquals(sourceSection, targetSection))
MoveWithinCollection<object>(Rows, source, target, placeBelow);
var listId = _currentList.Id["user:".Length..];
var orderedIds = Items.Select(i => i.Id).ToList();
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
await using var db = await _dbFactory.CreateDbContextAsync();
var idSet = orderedIds.ToHashSet();
var entities = await db.Tasks
.Where(t => t.ListId == listId && idSet.Contains(t.Id))
.ToListAsync();
for (int i = 0; i < orderedIds.Count; i++)
{
var e = entities.FirstOrDefault(x => x.Id == orderedIds[i]);
if (e is not null) e.SortOrder = i;
}
await db.SaveChangesAsync();
ok = true;
}
finally { OperationTiming.Shared.Record("db", "TasksIsland.ReorderAsync", sw.Elapsed, ok); }
}
private static void MoveWithinCollection<T>(
System.Collections.ObjectModel.ObservableCollection<T> coll,
T source,
T target,
bool placeBelow)
{
var srcIdx = coll.IndexOf(source);
var tgtIdx = coll.IndexOf(target);
if (srcIdx < 0 || tgtIdx < 0 || srcIdx == tgtIdx) return;
var finalIdx = placeBelow ? tgtIdx + 1 : tgtIdx;
if (srcIdx < finalIdx) finalIdx--;
if (finalIdx < 0) finalIdx = 0;
if (finalIdx >= coll.Count) finalIdx = coll.Count - 1;
if (finalIdx == srcIdx) return;
coll.Move(srcIdx, finalIdx);
}
// Reconcile a bound collection toward a target order using granular Remove/Move/Insert,
// so unchanged rows keep their containers (no Reset-driven full re-render).
private static void SyncCollection<T>(
System.Collections.ObjectModel.ObservableCollection<T> dst,
List<T> target)
{
var keep = new HashSet<T>(target);
for (int i = dst.Count - 1; i >= 0; i--)
if (!keep.Contains(dst[i]))
dst.RemoveAt(i);
for (int i = 0; i < target.Count; i++)
{
var item = target[i];
if (i < dst.Count && ReferenceEquals(dst[i], item)) continue;
var cur = dst.IndexOf(item);
if (cur >= 0) dst.Move(cur, i);
else dst.Insert(i, item);
}
}
// Marker returned for a row that sits in the flat Open section before any header — Open is the
// only section that can render without a preceding HeaderRow (no Overdue and ShowOpenLabel
// false). Distinct from null, which means "row not found in Rows at all".
private static readonly object UnlabeledSection = new();
// A row's section is read off the nearest preceding HeaderRow in Rows rather than
// re-classified from Done/ScheduledFor, so it always matches what Regroup actually rendered
// (including planning-parent edge cases the classification alone can't reconstruct).
private object? SectionFor(TaskRowViewModel row)
{
var idx = Rows.IndexOf(row);
if (idx < 0) return null;
for (int i = idx - 1; i >= 0; i--)
if (Rows[i] is HeaderRow header) return header;
return UnlabeledSection;
}
/// <summary>
/// Drag-drop move: reassigns a task (and every descendant) to <paramref name="targetList"/>.
/// No-op for anything that isn't a user list the task doesn't already sit in.
/// Rejects running tasks and tasks (or descendants) holding an Active/Kept worktree — the
/// worktree would keep pointing at the source repo. A move across repos without a worktree
/// is allowed but asks for confirmation first.
/// </summary>
public async Task MoveTaskToListAsync(TaskRowViewModel row, ListNavItemViewModel targetList)
{
if (targetList.Kind != ListKind.User) return;
var targetListId = targetList.Id.StartsWith("user:", StringComparison.Ordinal)
? targetList.Id["user:".Length..]
: targetList.Id;
await using var db = await _dbFactory.CreateDbContextAsync();
// The row's own list, not _currentList — the task island also shows smart/virtual lists,
// whose rows come from many lists and which carry no working dir of their own. Dropping a
// task on the list it already sits in is a silent no-op.
var sourceListId = await db.Tasks.AsNoTracking()
.Where(t => t.Id == row.Id).Select(t => t.ListId).FirstOrDefaultAsync();
if (sourceListId is null || sourceListId == targetListId) return;
if (row.IsRunning)
{
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.moveRunningRejected"));
return;
}
var repo = new TaskRepository(db);
var descendantIds = await repo.GetDescendantIdsAsync(row.Id);
var ownIds = new List<string> { row.Id };
ownIds.AddRange(descendantIds);
var hasBlockingWorktree = await db.Worktrees.AsNoTracking()
.Where(w => ownIds.Contains(w.TaskId)
&& (w.State == ClaudeDo.Data.Models.WorktreeState.Active
|| w.State == ClaudeDo.Data.Models.WorktreeState.Kept))
.AnyAsync();
if (hasBlockingWorktree)
{
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.moveWorktreeRejected"));
return;
}
var sourceDir = await db.Lists.AsNoTracking()
.Where(l => l.Id == sourceListId).Select(l => l.WorkingDir).FirstOrDefaultAsync();
var targetDir = targetList.WorkingDir;
var repoChanges = !string.Equals(sourceDir, targetDir, StringComparison.OrdinalIgnoreCase);
if (repoChanges)
{
if (Dialogs is null)
{
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.moveConfirmUnavailable"));
return;
}
var ok = await Dialogs.ConfirmAsync(Loc.T("vm.tasksIsland.moveRepoConfirm",
string.IsNullOrWhiteSpace(sourceDir) ? "—" : sourceDir,
string.IsNullOrWhiteSpace(targetDir) ? "—" : targetDir));
if (!ok) return;
}
await repo.MoveToListAsync(row.Id, targetListId);
Items.Remove(row);
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
private async Task ToggleDoneAsync(TaskRowViewModel row)
{
row.Done = !row.Done;
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
if (entity != null)
{
entity.Status = row.Done ? TaskStatus.Done : TaskStatus.Idle;
row.Status = entity.Status;
await db.SaveChangesAsync();
}
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
private async Task ClearCompletedAsync()
{
var (_, _, completed) = PartitionItems();
if (completed.Count == 0) return;
// Delete children before parents so the parent-child FK (Restrict) doesn't
// block removing a completed planning parent together with its done children.
var toDelete = completed.OrderByDescending(r => r.IsChild).ToList();
if (ConfirmAsync is not null)
{
var ok = await ConfirmAsync($"Clear {toDelete.Count} completed task(s)? This cannot be undone.");
if (!ok) return;
}
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
await using var db = await _dbFactory.CreateDbContextAsync();
var repo = new TaskRepository(db);
foreach (var row in toDelete)
{
try
{
await repo.DeleteAsync(row.Id);
Items.Remove(row);
}
catch { /* still referenced by open child tasks; leave it visible */ }
}
}
finally { OperationTiming.Shared.Record("db", "TasksIsland.ClearCompletedAsync", sw.Elapsed, ok: true); }
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
private async Task ToggleStarAsync(TaskRowViewModel row)
{
row.IsStarred = !row.IsStarred;
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
if (entity != null)
{
entity.IsStarred = row.IsStarred;
await db.SaveChangesAsync();
}
TasksChanged?.Invoke(this, EventArgs.Empty);
}
/// <summary>Flips a task between "Claude can run this" and "manual reminder". A manual task
/// hides every hand-off affordance and is skipped by the queue picker and daily prep.</summary>
[RelayCommand]
private async Task ToggleManualAsync(TaskRowViewModel? row)
{
if (row is null) return;
row.IsManual = !row.IsManual;
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
if (entity != null)
{
entity.IsManual = row.IsManual;
await db.SaveChangesAsync();
}
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
private async Task AddToMyDayAsync(TaskRowViewModel? row)
{
if (row is null || row.IsMyDay) return;
row.IsMyDay = true;
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
if (entity != null)
{
entity.IsMyDay = true;
await db.SaveChangesAsync();
}
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
private async Task RemoveFromMyDayAsync(TaskRowViewModel? row)
{
if (row is null) return;
row.IsMyDay = false;
await using var db = await _dbFactory.CreateDbContextAsync();
// Removing a parent takes its whole plan off My Day: clear the task and every child, so no
// orphaned child is left behind (independently-IsMyDay children included). A leaf child has
// no children of its own, so this collapses to just clearing the row itself.
var affected = await db.Tasks
.Where(t => t.Id == row.Id || t.ParentTaskId == row.Id)
.ToListAsync();
foreach (var t in affected)
t.IsMyDay = false;
if (affected.Count > 0)
await db.SaveChangesAsync();
if (_currentList?.Id == "smart:my-day")
{
var drop = Items
.Where(r => r.Id == row.Id || r.ParentTaskId == row.Id)
.ToList();
foreach (var r in drop)
Items.Remove(r);
}
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
public async Task SetStatusOnRowAsync(TaskRowViewModel row, TaskStatus status)
{
if (_worker is null) return;
try
{
var baseDirty = await _worker.SetTaskStatusAsync(row.Id, status);
ReportBaseDirty(baseDirty);
}
catch { /* offline; broadcast won't fire */ }
}
private void ReportBaseDirty(BaseDirtyWarningDto? warning)
{
if (warning is null) return;
ErrorReported?.Invoke(Loc.T(
"vm.queue.baseDirtyWarning", warning.ModifiedCount, warning.UntrackedCount));
}
[RelayCommand]
private async Task SendToQueueAsync(TaskRowViewModel? row)
{
if (row is null || row.IsRunning || row.HasInteractiveSession || _worker is null) return;
// A finalized planning parent queues its plan (children sequentially), not itself.
if (row.CanQueuePlan)
{
// The hub's QueuePlanningSubtasksAsync queues every Idle child unconditionally — it has
// no notion of a UI-hosted ConPTY session. Block the whole plan if any child has one open,
// otherwise that child's worktree would get an autonomous run racing the user's own edits.
var interactiveChildren = Items
.Where(r => r.ParentTaskId == row.Id && r.HasInteractiveSession)
.Select(r => r.Title)
.ToList();
if (interactiveChildren.Count > 0)
{
ErrorReported?.Invoke(Loc.T(
"vm.tasksIsland.queuePlanBlockedInteractive", string.Join(", ", interactiveChildren)));
return;
}
await QueuePlanningSubtasksAsync(row);
return;
}
// Goes through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write
// so the manual/draft-child guards apply here too; the row refreshes from the resulting
// TaskUpdated broadcast.
try
{
var baseDirty = await _worker.SetTaskStatusAsync(row.Id, TaskStatus.Queued);
ReportBaseDirty(baseDirty);
}
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.sendToQueueFailed", ex.Message)); }
}
[RelayCommand]
private async Task RemoveFromQueueAsync(TaskRowViewModel? row)
{
if (row is null) return;
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
if (entity is null) return;
// Cascade to queued children when present — covers both planning parents
// (PlanningPhase != None) and bare parents that have a manually-queued
// chain. The X button's visibility is gated by the same condition
// (HasQueuedSubtasks), so the handler matches what the user can see.
var queuedChildren = await db.Tasks
.Where(t => t.ParentTaskId == row.Id && t.Status == TaskStatus.Queued)
.ToListAsync();
foreach (var c in queuedChildren)
{
c.Status = TaskStatus.Idle;
c.BlockedByTaskId = null;
}
if (entity.Status == TaskStatus.Queued)
entity.Status = TaskStatus.Idle;
await db.SaveChangesAsync();
foreach (var c in queuedChildren)
{
var childRow = Items.FirstOrDefault(r => r.Id == c.Id);
if (childRow is not null)
{
childRow.Status = TaskStatus.Idle;
childRow.BlockedByTaskId = null;
}
}
if (row.Status == TaskStatus.Queued)
row.Status = TaskStatus.Idle;
row.HasQueuedSubtasks = false;
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
private async Task CancelRunningTaskAsync(TaskRowViewModel? row)
{
if (row is null || !row.IsRunning || _worker is null) return;
try { await _worker.CancelTaskAsync(row.Id); }
catch { /* worker offline; the broadcast will reconcile when it returns */ }
}
// ── Review actions (visible when a task is WaitingForReview) ─────────────
// Each delegates to the worker hub, which performs the transition and
// broadcasts TaskUpdated; the row refreshes from that broadcast.
[RelayCommand]
private async Task ApproveReviewAsync(TaskRowViewModel? row)
{
if (row is null || !row.IsWaitingForReview || _worker is null) return;
try { await _worker.ApproveReviewAsync(row.Id, ""); }
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.approveFailed", ex.Message)); }
}
public async Task RejectReviewToQueueAsync(TaskRowViewModel row, string feedback)
{
if (!row.IsWaitingForReview || _worker is null) return;
if (string.IsNullOrWhiteSpace(feedback)) return;
try { await _worker.RejectReviewToQueueAsync(row.Id, feedback); }
catch { /* offline; broadcast reconciles on return */ }
}
[RelayCommand]
private async Task RejectReviewToIdleAsync(TaskRowViewModel? row)
{
if (row is null || !row.IsWaitingForReview || _worker is null) return;
try { await _worker.RejectReviewToIdleAsync(row.Id); }
catch { /* offline; broadcast reconciles on return */ }
}
[RelayCommand]
private async Task CancelReviewAsync(TaskRowViewModel? row)
{
if (row is null || !row.IsWaitingForReview || _worker is null) return;
try { await _worker.CancelReviewAsync(row.Id); }
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.cancelReviewFailed", ex.Message)); }
}
public async Task SetScheduledForAsync(TaskRowViewModel row, DateTime? when)
{
if (row is null) return;
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
if (entity is null) return;
entity.ScheduledFor = when;
await db.SaveChangesAsync();
row.ScheduledFor = when;
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
private Task ClearScheduleAsync(TaskRowViewModel? row) =>
row is null ? Task.CompletedTask : SetScheduledForAsync(row, null);
[RelayCommand]
private void Select(TaskRowViewModel row) => SelectFrom(row, "row-click");
public async System.Threading.Tasks.Task<bool> SelectByIdAsync(string taskId)
{
if (LoadTask is { } lt)
{
try { await lt; } catch { /* load cancelled/failed — fall through */ }
}
var row = Items.FirstOrDefault(r => r.Id == taskId);
if (row is null) return false;
SelectFrom(row, "select-by-id");
return true;
}
[RelayCommand]
private void ToggleShowCompleted() => IsShowingCompleted = !IsShowingCompleted;
// The completed section is only emitted into Rows while the toggle is on — flipping it
// doesn't change grouping/order data, so a full reload isn't needed, just a re-emit.
partial void OnIsShowingCompletedChanged(bool value) => Regroup();
public event EventHandler? OpenListSettingsRequested;
[RelayCommand]
private void OpenListSettings() => OpenListSettingsRequested?.Invoke(this, EventArgs.Empty);
[RelayCommand]
private void OpenPlanningSession(TaskRowViewModel? row)
{
if (row is null) return;
if (row.Status != TaskStatus.Idle || row.PlanningPhase != PlanningPhase.None) return;
// Planning now runs as an embedded ConPTY pane in the Command Center (not an external wt
// window). The shell owns Mission Control, so raise an event; the actual StartAsync happens
// server-side inside GetPlanningStartLaunchSpec when the pane opens.
OpenPlanningConPtyRequested?.Invoke(row.Id, false);
}
// Opens the task in an embedded ConPTY terminal pane in the Command Center. The shell owns
// the Mission Control view model, so this just raises an event for it to act on.
public event Action<string>? OpenConPtySessionRequested;
// Opens (resume=false) or resumes (resume=true) a planning session as an embedded ConPTY
// pane in the Command Center.
public event Action<string, bool>? OpenPlanningConPtyRequested;
[RelayCommand]
private void OpenConPtySession(TaskRowViewModel? row)
{
if (row is null) return;
OpenConPtySessionRequested?.Invoke(row.Id);
}
[RelayCommand]
private async Task ResumePlanningSessionAsync(TaskRowViewModel? row)
{
if (row is null || !row.IsPlanningParent) return;
if (_worker is null) return;
try
{
var draftCount = await _worker.GetPendingDraftCountAsync(row.Id);
var modalVm = new UnfinishedPlanningModalViewModel
{
TaskTitle = row.Title,
DraftCount = draftCount,
};
if (ShowUnfinishedPlanningModal is null)
return;
await ShowUnfinishedPlanningModal(modalVm);
var choice = await modalVm.Result.Task;
switch (choice)
{
case UnfinishedPlanningModalResult.Resume:
// Resume as an embedded ConPTY pane (server-side ResumeAsync runs inside
// GetPlanningResumeLaunchSpec when the pane opens).
OpenPlanningConPtyRequested?.Invoke(row.Id, true);
break;
case UnfinishedPlanningModalResult.FinalizeNow:
await FinalizePlanningSessionAsync(row);
break;
case UnfinishedPlanningModalResult.Discard:
await TryDiscardPlanningWithRetryAsync(row.Id);
break;
case UnfinishedPlanningModalResult.Cancel:
default:
break;
}
}
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.planningResumeFailed", ex.Message)); }
}
[RelayCommand]
private async Task DiscardPlanningSessionAsync(TaskRowViewModel? row)
{
if (row is null || _worker is null) return;
await TryDiscardPlanningWithRetryAsync(row.Id);
}
/// <summary>
/// Calls discard, and if it is blocked because children are queued, prompts the
/// user to dequeue them and retries. Running children are surfaced as a hard
/// block — the user must cancel them first.
/// </summary>
private async Task TryDiscardPlanningWithRetryAsync(string taskId)
{
if (_worker is null) return;
DiscardPlanningOutcome outcome;
try { outcome = await _worker.DiscardPlanningSessionAsync(taskId); }
catch { return; }
if (outcome.Result == DiscardPlanningResult.BlockedByQueuedChildren)
{
if (ConfirmAsync is null) return;
var ok = await ConfirmAsync(
$"{outcome.QueuedChildrenCount} child task(s) are queued.\n" +
"Dequeue them and discard the planning session?");
if (!ok) return;
try { await _worker.DiscardPlanningSessionAsync(taskId, dequeueQueuedChildren: true); }
catch { }
}
else if (outcome.Result == DiscardPlanningResult.BlockedByRunningChildren)
{
if (ConfirmAsync is null) return;
await ConfirmAsync(
$"{outcome.RunningChildrenCount} child task(s) are still running.\n" +
"Cancel them first, then try again.");
}
}
/// <summary>
/// Wired by the view via <see cref="ShowConfirmAsync"/>. Returns true when the user confirms.
/// </summary>
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
private bool CanRunPlanningOperation() => !PlanningOperation.IsRunning;
[RelayCommand(CanExecute = nameof(CanRunPlanningOperation))]
private async Task QueuePlanningSubtasksAsync(TaskRowViewModel? row)
{
if (row is null || _worker is null) return;
using var op = PlanningOperation.Begin(Loc.T("ops.planning.queuingSubtasks"));
try { await _worker.QueuePlanningSubtasksAsync(row.Id); }
catch { }
}
[RelayCommand(CanExecute = nameof(CanRunPlanningOperation))]
private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
{
if (row is null) return;
using var op = PlanningOperation.Begin(Loc.T("ops.planning.finalizing"));
try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false); }
catch { }
}
[RelayCommand]
private void ToggleExpand(TaskRowViewModel? row)
{
if (row is null) return;
var next = !(_expandedState.TryGetValue(row.Id, out var current) ? current : row.IsExpanded);
_expandedState[row.Id] = next;
row.IsExpanded = next;
Regroup();
}
[RelayCommand]
private async Task RefineTask(TaskRowViewModel row)
{
if (row is null || !row.CanRefine) return;
row.IsRefining = true;
try { await _worker!.RefineTaskAsync(row.Id); }
catch { row.IsRefining = false; }
}
private void OnRefineStarted(string taskId)
{
var row = Items.FirstOrDefault(r => r.Id == taskId);
if (row is not null) row.IsRefining = true;
}
private void OnRefineFinished(string taskId, bool ok, string? error)
{
var row = Items.FirstOrDefault(r => r.Id == taskId);
if (row is not null) row.IsRefining = false;
}
partial void OnSelectedTaskChanged(TaskRowViewModel? value)
{
foreach (var i in Items) i.IsSelected = ReferenceEquals(i, value);
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
// ── Phase 3: reconcile tick ──────────────────────────────────────────────
// Self-healing safety net for a lost broadcast: every few seconds, diff the flat `Items`
// master collection against SQLite and patch properties in place. Never rebuilds a row and
// never falls back to LoadForList — the delta path already owns that escalation. It DOES
// call Regroup(), but only when a patch actually moved a row's grouping inputs: a healed
// row can change section, order or rail label, and none of that shows until Rows is
// re-emitted.
// Above this many rows, only the first N (in Items order) are reconciled per tick, so the
// query cost stays bounded instead of growing with an ever-larger list.
internal const int ReconcileRowCap = 500;
// Set by the shell from the main window's WindowState; the tick no-ops while minimized.
public bool IsWindowVisible { get; set; } = true;
// Test seam: awaited right after the reconcile query returns and before its results are
// applied, so a test can land a fresher, higher-sequence update in that gap and exercise the
// stale-sequence guard deterministically. Always null outside tests.
internal Func<Task>? ReconcileTickTestBarrier { get; set; }
// Awaitable so tests can drive it deterministically (mirrors RefreshTaskFromWorkerAsync).
internal async Task ReconcileTickAsync(CancellationToken ct = default)
{
var list = _currentList;
if (!IsWindowVisible || list is null) return;
// Mirrors the full-reload guard in RefreshTaskFromWorkerAsync: matching virtual:queued /
// virtual:running depends on a Planning parent's children, not a single entity.
if (list.Kind == ListKind.Virtual &&
(list.Id == "virtual:queued" || list.Id == "virtual:running"))
return;
var ids = Items.Select(r => r.Id).Take(ReconcileRowCap).ToList();
if (ids.Count == 0) return;
// Same monotonic per-task sequence as the Phase 1 delta path, so whichever of the two
// started most recently for a given task id wins, regardless of completion order.
var seqByTaskId = new Dictionary<string, long>(ids.Count);
foreach (var id in ids)
{
var seq = ++_deltaCounter;
seqByTaskId[id] = seq;
_deltaSeq[id] = seq;
}
List<TaskEntity> entities;
var sw = System.Diagnostics.Stopwatch.StartNew();
var dbOk = false;
try
{
var idSet = ids.ToHashSet();
await using var db = await _dbFactory.CreateDbContextAsync(ct);
entities = await db.Tasks
.Include(t => t.List)
.Include(t => t.Worktree)
.Where(t => idSet.Contains(t.Id))
.ToListAsync(ct);
dbOk = true;
}
catch (OperationCanceledException) { return; }
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"TasksIsland: reconcile tick failed ({ex.Message})");
return;
}
finally { OperationTiming.Shared.Record("db", "TasksIsland.ReconcileTickAsync", sw.Elapsed, dbOk); }
if (ReconcileTickTestBarrier is { } barrier) await barrier();
var byId = entities.ToDictionary(e => e.Id);
// Index the rows once instead of scanning Items per id — at the 500-row cap a linear
// scan per id is 250k comparisons every few seconds, for nothing.
var rowById = new Dictionary<string, TaskRowViewModel>(Items.Count);
foreach (var r in Items) rowById[r.Id] = r;
var groupingChanged = false;
foreach (var id in ids)
{
// Superseded by a fresher delta refresh or a later tick that landed while this one
// was reading — its result is stale, discard it.
if (!_deltaSeq.TryGetValue(id, out var current) || current != seqByTaskId[id]) continue;
if (!byId.TryGetValue(id, out var entity)) continue; // deleted; the delta path removes rows, not the tick
if (!rowById.TryGetValue(id, out var row)) continue;
var before = GroupingKey(row);
row.UpdateFromEntity(entity);
if (!before.Equals(GroupingKey(row))) groupingChanged = true;
}
// A patched row can have left its section (Done), moved inside it (a new/removed
// depends-on link re-orders the chain), or changed what its rail label reads. Rows only
// 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();
}
// The slice of a row that Regroup reads: which section it lands in, where it sits inside it,
// and what its chain rail/after-chip says. Only fields UpdateFromEntity actually writes —
// IsExpanded and HasPlanningChildren are owned elsewhere and would produce false positives.
private static (bool, DateTime?, string?, string?, PlanningPhase, int, string) GroupingKey(TaskRowViewModel r) =>
(r.Done, r.ScheduledFor, r.ParentTaskId, r.DependsOnTaskId, r.PlanningPhase, r.Number, r.Title);
}