LoadForListAsync loaded the entire tasks table (all lists, two Include joins) then filtered to the selected list in C#, never using idx_tasks_list_id/idx_tasks_status. ITaskListFilter now exposes MatchExpression, an Expression<Func<TaskEntity,bool>> that EF Core can translate to SQL; a new TaskListFilterBase compiles it on demand for the existing in-memory Matches(). The primary match query now runs as db.Tasks.Where(filter.MatchExpression), with two small follow-up queries (scoped by parent-id IN-lists) for contextual planning-parent rows and pulled-in children, followed by an explicit re-sort to restore the global SortOrder/CreatedAt ordering Regroup() depends on.
1214 lines
49 KiB
C#
1214 lines
49 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Globalization;
|
|
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;
|
|
|
|
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;
|
|
|
|
public event EventHandler? SelectionChanged;
|
|
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()
|
|
{
|
|
SelectedTask = null;
|
|
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();
|
|
public ObservableCollection<TaskRowViewModel> OverdueItems { get; } = new();
|
|
public ObservableCollection<TaskRowViewModel> OpenItems { get; } = new();
|
|
public ObservableCollection<TaskRowViewModel> CompletedItems { get; } = new();
|
|
|
|
[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;
|
|
|
|
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;
|
|
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;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Loc.LanguageChanged -= _langChangedHandler;
|
|
}
|
|
|
|
private void RefreshLocalizedText()
|
|
{
|
|
CompletedHeader = Loc.T("vm.tasksIsland.completedHeader");
|
|
foreach (var row in Items) row.RefreshLocalized();
|
|
foreach (var row in CompletedItems) row.RefreshLocalized();
|
|
}
|
|
|
|
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();
|
|
OverdueItems.Clear();
|
|
OpenItems.Clear();
|
|
CompletedItems.Clear();
|
|
HasOverdue = false;
|
|
HasOpen = false;
|
|
HasCompleted = false;
|
|
ShowOpenLabel = false;
|
|
ShowNotesRow = false;
|
|
if (listChanged) SelectedTask = null;
|
|
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)
|
|
{
|
|
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();
|
|
}
|
|
catch (OperationCanceledException) { }
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Build hierarchy-aware flat list: top-level rows interleaved with visible children.
|
|
// 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 in CompletedItems alongside the parent.
|
|
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);
|
|
}
|
|
|
|
// Reconcile the bound collections in place (granular Insert/Move/Remove) rather than
|
|
// Clear+Add, so toggling a parent only touches its own child rows — the ItemsControl
|
|
// keeps every unchanged container instead of tearing the whole list down on a Reset.
|
|
SyncCollection(OverdueItems, overdue);
|
|
SyncCollection(OpenItems, open);
|
|
SyncCollection(CompletedItems, completed);
|
|
|
|
HasOverdue = OverdueItems.Count > 0;
|
|
HasOpen = OpenItems.Count > 0;
|
|
HasCompleted = CompletedItems.Count > 0;
|
|
ShowOpenLabel = HasOpen && HasOverdue;
|
|
CompletedHeader = Loc.T("vm.tasksIsland.completedHeaderCount", CompletedItems.Count);
|
|
}
|
|
|
|
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();
|
|
SelectedTask = row;
|
|
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 whichever section the row lives in.
|
|
// 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(sourceSection, source, target, placeBelow);
|
|
|
|
var listId = _currentList.Id["user:".Length..];
|
|
var orderedIds = Items.Select(i => i.Id).ToList();
|
|
|
|
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();
|
|
}
|
|
|
|
private static void MoveWithinCollection(
|
|
System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel> coll,
|
|
TaskRowViewModel source,
|
|
TaskRowViewModel 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(
|
|
System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel> dst,
|
|
List<TaskRowViewModel> target)
|
|
{
|
|
var keep = new HashSet<TaskRowViewModel>(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);
|
|
}
|
|
}
|
|
|
|
private System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel>? SectionFor(TaskRowViewModel row)
|
|
{
|
|
if (OverdueItems.Contains(row)) return OverdueItems;
|
|
if (OpenItems.Contains(row)) return OpenItems;
|
|
if (CompletedItems.Contains(row)) return CompletedItems;
|
|
return null;
|
|
}
|
|
|
|
/// <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()
|
|
{
|
|
if (CompletedItems.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 = CompletedItems.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;
|
|
}
|
|
|
|
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 */ }
|
|
}
|
|
|
|
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 { await _worker.SetTaskStatusAsync(row.Id, status); }
|
|
catch { /* offline; broadcast won't fire */ }
|
|
}
|
|
|
|
[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 { await _worker.SetTaskStatusAsync(row.Id, TaskStatus.Queued); }
|
|
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) => SelectedTask = row;
|
|
|
|
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;
|
|
SelectedTask = row;
|
|
return true;
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void ToggleShowCompleted() => IsShowingCompleted = !IsShowingCompleted;
|
|
|
|
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 _worker.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false);
|
|
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; }
|
|
|
|
[RelayCommand]
|
|
private async Task QueuePlanningSubtasksAsync(TaskRowViewModel? row)
|
|
{
|
|
if (row is null || _worker is null) return;
|
|
try { await _worker.QueuePlanningSubtasksAsync(row.Id); }
|
|
catch { }
|
|
}
|
|
|
|
[RelayCommand]
|
|
private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
|
|
{
|
|
if (row is null) return;
|
|
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);
|
|
}
|
|
}
|