The DB's global UtcConverter only tags DateTimes as Utc on read (SpecifyKind), it never converts on write. SetScheduledForAsync persisted the ThemedDatePicker's Local/Unspecified wall-clock value verbatim, so QueuePicker's comparison against DateTime.UtcNow fired scheduled tasks late by the local UTC offset (e.g. 2h in CEST). Convert to UTC at the write boundary, and ToLocalTime() at the read/compare sites (overdue checks in TaskRowViewModel/TasksIslandViewModel, the date-picker's edit seed) so existing scheduled/overdue display doesn't shift. Existing DB rows hold local wall-clock values mistagged as Utc; no migration added (few rows, one-time 2h-class shift accepted per the originating audit finding).
1701 lines
72 KiB
C#
1701 lines
72 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]
|
|
[NotifyPropertyChangedFor(nameof(IsTasksEmptyHintVisible))]
|
|
[NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))]
|
|
private bool _hasOverdue;
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(IsTasksEmptyHintVisible))]
|
|
[NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))]
|
|
private bool _hasOpen;
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(IsTasksEmptyHintVisible))]
|
|
[NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))]
|
|
private bool _hasCompleted;
|
|
[ObservableProperty] private bool _showOpenLabel;
|
|
[ObservableProperty] private string _completedHeader = "";
|
|
[ObservableProperty] private bool _showNotesRow;
|
|
[ObservableProperty] private bool _isMyDayList;
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))]
|
|
private bool _isLetClaudeVisible;
|
|
[ObservableProperty] private bool _isQuickClaudeVisible;
|
|
|
|
/// <summary>No visible tasks below the add-task row — every item lands in one of
|
|
/// Overdue/Open/Completed, so all-false here always means the list has zero items.</summary>
|
|
public bool IsTasksEmptyHintVisible => !HasOverdue && !HasOpen && !HasCompleted;
|
|
|
|
/// <summary>Extra empty-state line for a User list with no linked working dir, where the
|
|
/// execution features (queue, Let Claude handle it, Quick session) are unavailable. Derived
|
|
/// separately from <see cref="IsLetClaudeVisible"/> so Smart/Virtual lists — which also have
|
|
/// no working dir but aren't missing a repo link — don't pick it up.</summary>
|
|
public bool IsTasksEmptyRepoHintVisible =>
|
|
IsTasksEmptyHintVisible && _currentList?.Kind == ListKind.User && !IsLetClaudeVisible;
|
|
|
|
// 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;
|
|
_worker.OperationProgressEvent += OnWorkerOperationProgress;
|
|
}
|
|
_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;
|
|
|
|
// IsTasksEmptyRepoHintVisible reads _currentList?.Kind directly, but Kind isn't itself an
|
|
// observed property — the [NotifyPropertyChangedFor] chain only fires when IsLetClaudeVisible's
|
|
// *value* changes. Switching between two lists that both resolve IsLetClaudeVisible to the
|
|
// same bool (e.g. a Smart list -> an empty User list without a WorkingDir) changes Kind
|
|
// without ever notifying, leaving the hint stale. Force it explicitly on every list switch.
|
|
OnPropertyChanged(nameof(IsTasksEmptyRepoHintVisible));
|
|
|
|
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.ToLocalTime().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)
|
|
{
|
|
if (_worker is null) return;
|
|
|
|
var newDone = !row.Done;
|
|
var previousStatus = row.Status;
|
|
row.Done = newDone;
|
|
row.Status = newDone ? TaskStatus.Done : TaskStatus.Idle;
|
|
try
|
|
{
|
|
if (newDone) await _worker.SetTaskDoneAsync(row.Id);
|
|
else await _worker.UnsetTaskDoneAsync(row.Id);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
row.Done = !newDone;
|
|
row.Status = previousStatus;
|
|
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.toggleDoneFailed", ex.Message));
|
|
return;
|
|
}
|
|
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 (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.setStatusFailed", ex.Message)); }
|
|
}
|
|
|
|
private void ReportBaseDirty(BaseDirtyWarningDto? warning)
|
|
{
|
|
if (warning is null) return;
|
|
ErrorReported?.Invoke(Loc.T(
|
|
"vm.queue.baseDirtyWarning", warning.ModifiedCount, warning.UntrackedCount));
|
|
}
|
|
|
|
// Row-level delete for the task context menu. Routed through the worker (mirrors
|
|
// DetailsIslandViewModel.DeleteTaskAsync) so a deleted child correctly advances a
|
|
// WaitingForChildren parent instead of bypassing TaskStateService.
|
|
[RelayCommand]
|
|
private async Task DeleteTaskAsync(TaskRowViewModel? row)
|
|
{
|
|
if (row is null || _worker is null) return;
|
|
if (ConfirmAsync is not null)
|
|
{
|
|
var ok = await ConfirmAsync(Loc.T("vm.tasksIsland.deleteTaskConfirm", row.Title));
|
|
if (!ok) return;
|
|
}
|
|
|
|
bool deleted;
|
|
string? error;
|
|
try
|
|
{
|
|
(deleted, error) = await _worker.DeleteTaskAsync(row.Id);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.deleteTaskFailed", ex.Message));
|
|
return;
|
|
}
|
|
if (!deleted)
|
|
{
|
|
ErrorReported?.Invoke(error ?? Loc.T("vm.tasksIsland.deleteTaskFailed", "unknown error"));
|
|
return;
|
|
}
|
|
|
|
Items.Remove(row);
|
|
Regroup();
|
|
UpdateSubtitle();
|
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
[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 || _worker 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.
|
|
List<string> queuedChildIds;
|
|
await using (var db = await _dbFactory.CreateDbContextAsync())
|
|
{
|
|
queuedChildIds = await db.Tasks.AsNoTracking()
|
|
.Where(t => t.ParentTaskId == row.Id && t.Status == TaskStatus.Queued)
|
|
.Select(t => t.Id)
|
|
.ToListAsync();
|
|
}
|
|
|
|
var failures = new List<string>();
|
|
|
|
foreach (var childId in queuedChildIds)
|
|
{
|
|
var childRow = Items.FirstOrDefault(r => r.Id == childId);
|
|
if (childRow is null) continue;
|
|
var previousBlockedBy = childRow.BlockedByTaskId;
|
|
childRow.Status = TaskStatus.Idle;
|
|
childRow.BlockedByTaskId = null;
|
|
try { await _worker.DequeueTaskAsync(childId); }
|
|
catch (Exception ex)
|
|
{
|
|
childRow.Status = TaskStatus.Queued;
|
|
childRow.BlockedByTaskId = previousBlockedBy;
|
|
failures.Add(ex.Message);
|
|
}
|
|
}
|
|
|
|
if (row.Status == TaskStatus.Queued)
|
|
{
|
|
row.Status = TaskStatus.Idle;
|
|
try { await _worker.DequeueTaskAsync(row.Id); }
|
|
catch (Exception ex)
|
|
{
|
|
row.Status = TaskStatus.Queued;
|
|
failures.Add(ex.Message);
|
|
}
|
|
}
|
|
|
|
row.HasQueuedSubtasks = queuedChildIds
|
|
.Select(id => Items.FirstOrDefault(r => r.Id == id))
|
|
.Any(r => r?.Status == TaskStatus.Queued);
|
|
|
|
if (failures.Count > 0)
|
|
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.removeFromQueueFailed", string.Join("; ", failures)));
|
|
|
|
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 (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.cancelFailed", ex.Message)); }
|
|
}
|
|
|
|
// ── 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 (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.rejectToQueueFailed", ex.Message)); }
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task RejectReviewToIdleAsync(TaskRowViewModel? row)
|
|
{
|
|
if (row is null || !row.IsWaitingForReview || _worker is null) return;
|
|
try { await _worker.RejectReviewToIdleAsync(row.Id); }
|
|
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.rejectToIdleFailed", ex.Message)); }
|
|
}
|
|
|
|
[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;
|
|
// ThemedDatePicker builds Unspecified/Local wall-clock values; the DB's UtcConverter only
|
|
// tags DateTimes as Utc on read, it never converts on write, so this is the one place that
|
|
// must turn "local wall clock" into a real UTC instant before it's persisted.
|
|
var whenUtc = when is { } w
|
|
? (w.Kind == DateTimeKind.Utc ? w : DateTime.SpecifyKind(w, DateTimeKind.Local).ToUniversalTime())
|
|
: (DateTime?)null;
|
|
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 = whenUtc;
|
|
await db.SaveChangesAsync();
|
|
row.ScheduledFor = whenUtc;
|
|
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 void OpenTaskWorktree(TaskRowViewModel? row)
|
|
{
|
|
var (ok, error) = ShellOpen.Path(row?.WorktreePath);
|
|
if (!ok && error is not null)
|
|
ErrorReported?.Invoke(Loc.T("vm.tasksIsland.openWorktreeFailed", error));
|
|
}
|
|
|
|
[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;
|
|
}
|
|
|
|
// OperationProgress is a generic channel shared with merge phases (TaskMergeService), whose
|
|
// opKey is also the task id — filter on the phase token so a merge in flight can't clobber
|
|
// this row's creation-phase banner.
|
|
private const string CreationPhaseCreatingWorktree = "creating_worktree";
|
|
|
|
private void OnWorkerOperationProgress(string opKey, string phase, int current, int total)
|
|
{
|
|
if (phase != CreationPhaseCreatingWorktree) return;
|
|
var row = Items.FirstOrDefault(r => r.Id == opKey);
|
|
if (row is not null) row.CreationPhase = phase;
|
|
}
|
|
|
|
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);
|
|
}
|