Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
T
mika kuns ef285b21fd feat(planning): run interactive planning sessions via embedded ConPTY
Planning start/resume opened an external Windows Terminal (wt) window.
Route them through the embedded ConPTY Command Center pane instead, matching
the existing interactive-session UX (no external window).

- Extract bare planning arg builders (BuildPlanningStartArgs/ResumeArgs) from
  WindowsTerminalLauncher; the wt path still uses them (kept, not removed).
- InteractiveLaunchSpecService.BuildPlanningStart/Resume map a planning
  context into a LaunchSpec (planning args + env: MAX_THINKING_TOKENS,
  CLAUDEDO_PLANNING_TOKEN). Hub GetPlanningStart/ResumeLaunchSpec run
  StartAsync/ResumeAsync then return the spec.
- UI: OpenPlanningSession + the resume branch raise OpenPlanningConPtyRequested;
  the shell opens Mission Control and hosts a planning ConPTY pane.

Env is process-global by design (sequential human-paced sessions). wt planning
code retained. Tests added for the arg/env mapping.
2026-07-24 12:12:23 +02:00

987 lines
38 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;
private static readonly TaskListFilterRegistry _filters = new();
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;
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)
{
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;
}
try
{
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 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();
}
catch { }
}
// 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 (e.PropertyName == nameof(ListNavItemViewModel.Name) && sender is ListNavItemViewModel vm)
HeaderTitle = vm.Name;
}
public void LoadForList(ListNavItemViewModel? list)
{
_loadCts?.Cancel();
_loadCts?.Dispose();
_loadCts = new CancellationTokenSource();
var ct = _loadCts.Token;
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 (list is null) { 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";
LoadTask = LoadForListAsync(list, ct);
}
private async Task LoadForListAsync(ListNavItemViewModel list, CancellationToken ct)
{
try
{
await using var db = await _dbFactory.CreateDbContextAsync(ct);
var all = await db.Tasks
.Include(t => t.List)
.Include(t => t.Worktree)
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.ToListAsync(ct);
ct.ThrowIfCancellationRequested();
var filter = _filters.Resolve(list.Id);
var filteredList = filter is null
? new List<TaskEntity>()
: all.Where(t => filter.Matches(t) || filter.MatchesAsContext(t, all)).ToList();
var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet();
var existingIds = filteredList.Select(t => t.Id).ToHashSet();
foreach (var c in all.Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId!)))
{
if (existingIds.Add(c.Id))
filteredList.Add(c);
}
var showListChip = list.Kind == ListKind.Virtual;
foreach (var t in filteredList)
{
var row = TaskRowViewModel.FromEntity(t);
row.ShowListChip = showListChip;
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();
var entity = new TaskEntity
{
Id = Guid.NewGuid().ToString("N"),
ListId = listId,
Title = NewTaskTitle.Trim(),
Status = TaskStatus.Idle,
CreatedAt = DateTime.UtcNow,
};
await new TaskRepository(db).AddAsync(entity);
var row = TaskRowViewModel.FromEntity(entity);
row.ShowListChip = _currentList?.Kind == ListKind.Virtual;
Items.Add(row);
Regroup();
NewTaskTitle = "";
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
public bool CanReorder => _currentList?.Kind == ListKind.User;
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;
}
[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);
}
[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) return;
// A finalized planning parent queues its plan (children sequentially), not itself.
if (row.CanQueuePlan)
{
await QueuePlanningSubtasksAsync(row);
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.Status = TaskStatus.Queued;
await db.SaveChangesAsync();
row.Status = TaskStatus.Queued;
if (_worker is not null)
{
try { await _worker.WakeQueueAsync(); } catch { }
}
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[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 { /* offline; broadcast reconciles on return */ }
}
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 PickUpInTerminalAsync(TaskRowViewModel? row)
{
if (row is null || _worker is null) return;
ForegroundHelper.AllowAny();
try { await _worker.ResumeTaskInTerminalAsync(row.Id); }
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.pickUpInTerminalFailed", ex.Message)); }
}
[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);
}
}