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; /// A group header entry in — the flat list's /// other union member alongside . Instances are persistent (one per /// section, mutated in place by ) so an unchanged header /// keeps its identity across a reconcile. 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 _dbFactory; private readonly IWorkerClient? _worker; private readonly Dictionary _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 _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 _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; /// 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. public string SelectionSource { get; private set; } = "init"; /// Sets and tags what triggered it — use this instead of /// assigning the property, otherwise the bind is logged as "?" . 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? 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 Items { get; } = new(); /// Flat, virtualization-friendly view of : group headers /// () interleaved with visible rows, in /// display order. Reconciled granularly by — never Clear+Add. public ObservableCollection Rows { get; } = new(); private readonly HeaderRow _overdueHeaderRow = new() { IsOverdue = true }; private readonly HeaderRow _openHeaderRow = new(); private readonly HeaderRow _completedHeaderRow; [ObservableProperty] private string _newTaskTitle = ""; [ObservableProperty] private TaskRowViewModel? _selectedTask; [ObservableProperty] private string _headerTitle = ""; [ObservableProperty] private string _headerEyebrow = ""; [ObservableProperty] private string _subtitle = ""; [ObservableProperty] private string _statusPill = ""; [ObservableProperty] private bool _hasStatusPill; [ObservableProperty] private bool _isShowingCompleted = true; [ObservableProperty] private bool _hasOverdue; [ObservableProperty] private bool _hasOpen; [ObservableProperty] private bool _hasCompleted; [ObservableProperty] private bool _showOpenLabel; [ObservableProperty] private string _completedHeader = ""; [ObservableProperty] private bool _showNotesRow; [ObservableProperty] private bool _isMyDayList; [ObservableProperty] private bool _isLetClaudeVisible; [ObservableProperty] private bool _isQuickClaudeVisible; // Shared by QueuePlanningSubtasksAsync and FinalizePlanningSessionAsync — both are triggered // from a context menu that closes the instant a click lands, so there is no per-row surface // left standing to anchor an indicator to. The island header is the nearest surface still // visible after the menu closes. public OperationStatus PlanningOperation { get; } = new(); public event EventHandler? LetClaudeHandleRequested; [RelayCommand] private void LetClaudeHandle() => LetClaudeHandleRequested?.Invoke(this, EventArgs.Empty); // Opens a task-less ConPTY session directly in the current list's working dir. The shell owns // Mission Control, so this just raises an event for it to act on (mirrors OpenConPtySessionRequested). public event Action? 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? ShowUnfinishedPlanningModal { get; set; } private readonly EventHandler _langChangedHandler; public TasksIslandViewModel(IDbContextFactory dbFactory, IWorkerClient? worker = null) { _dbFactory = dbFactory; _worker = worker; _completedHeaderRow = new() { ActionCommand = ClearCompletedCommand }; CompletedHeader = Loc.T("vm.tasksIsland.completedHeader"); if (_worker is not null) { _worker.TaskUpdatedEvent += OnWorkerTaskUpdated; _worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated; _worker.ListUpdatedEvent += OnWorkerListUpdated; _worker.ConnectionRestoredEvent += () => LoadForList(_currentList); _worker.RefineStartedEvent += OnRefineStarted; _worker.RefineFinishedEvent += OnRefineFinished; } _langChangedHandler = (_, _) => RefreshLocalizedText(); Loc.LanguageChanged += _langChangedHandler; _reconcileTimer.Elapsed += (_, _) => Dispatcher.UIThread.Post(() => _ = ReconcileTickAsync()); _reconcileTimer.Start(); // OperationStatus is a nested ObservableObject — [NotifyCanExecuteChangedFor] can't see // into it, so IsRunning changes are relayed by hand. PlanningOperation.PropertyChanged += (_, e) => { if (e.PropertyName != nameof(OperationStatus.IsRunning)) return; QueuePlanningSubtasksCommand.NotifyCanExecuteChanged(); FinalizePlanningSessionCommand.NotifyCanExecuteChanged(); }; } public void Dispose() { Loc.LanguageChanged -= _langChangedHandler; _reconcileTimer.Stop(); _reconcileTimer.Dispose(); } private void RefreshLocalizedText() { CompletedHeader = Loc.T("vm.tasksIsland.completedHeader"); foreach (var row in Items) row.RefreshLocalized(); // The section headers are now Rows entries whose Label is a resolved string, not a live // {loc:Tr} binding in the view — without re-emitting them they keep the old language. Regroup(); } private async void OnWorkerListUpdated(string listId) { // Mirror the renamed list onto every task row that references it, // so the per-row ListName chip on virtual lists stays current. try { await using var db = await _dbFactory.CreateDbContextAsync(); var entity = await db.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == listId); if (entity is null) return; var visibleIds = Items.Select(r => r.Id).ToHashSet(); if (visibleIds.Count == 0) return; var matchingIds = await db.Tasks.AsNoTracking() .Where(t => t.ListId == listId && visibleIds.Contains(t.Id)) .Select(t => t.Id) .ToListAsync(); var matching = matchingIds.ToHashSet(); foreach (var row in Items) if (matching.Contains(row.Id) && row.ListName != entity.Name) row.ListName = entity.Name; } catch { } } private async void OnWorkerTaskUpdated(string taskId) => await RefreshTaskFromWorkerAsync(taskId); // Awaitable so tests can drive it deterministically. One retry, then a full reload: // a swallowed exception here used to leave the row on a stale status permanently. internal async Task RefreshTaskFromWorkerAsync(string taskId) { var list = _currentList; if (list is null) return; // virtual:queued / virtual:running include Planning parents whose children match, // which can't be decided from a single entity. Always full-reload in those cases. if (list.Kind == ListKind.Virtual && (list.Id == "virtual:queued" || list.Id == "virtual:running")) { LoadForList(list); return; } var seq = ++_deltaCounter; _deltaSeq[taskId] = seq; try { await ApplyDeltaAsync(taskId, list, seq); } catch (Exception first) { System.Diagnostics.Debug.WriteLine( $"TasksIsland: delta refresh for {taskId} failed ({first.Message}); retrying"); try { await ApplyDeltaAsync(taskId, list, seq); } catch (Exception second) { System.Diagnostics.Debug.WriteLine( $"TasksIsland: delta retry for {taskId} failed ({second.Message}); full reload"); LoadForList(list); } } } private async Task ApplyDeltaAsync(string taskId, ListNavItemViewModel list, long seq) { await using var db = await _dbFactory.CreateDbContextAsync(); var entity = await db.Tasks .Include(t => t.List) .Include(t => t.Worktree) .FirstOrDefaultAsync(t => t.Id == taskId); // A newer refresh for this task started while we were reading — its result is fresher. if (_deltaSeq.TryGetValue(taskId, out var current) && current != seq) return; // A parent transition (finalize/discard) broadcasts only the parent's id, but it // changes its children's derived state — finalize flips them Draft→Planned, discard // deletes them. The delta path below only touches the parent row and never recomputes // the child-derived flags (ParentFinalized, HasPlanningChildren) nor drops deleted // children, so reconcile the whole list when the updated task is (or owns) a subtree. if (entity is not null && (entity.PlanningPhase != PlanningPhase.None || Items.Any(r => r.ParentTaskId == entity.Id))) { LoadForList(list); return; } var existing = Items.FirstOrDefault(r => r.Id == taskId); if (entity is null) { if (existing is not null) Items.Remove(existing); } else { var matches = TaskMatchesList(entity, list); if (existing is not null && matches) existing.UpdateFromEntity(entity); else if (existing is not null) Items.Remove(existing); else if (matches) { LoadForList(list); return; } else return; } // Keep the parent's HasQueuedSubtasks flag in sync when a child's status flips. if (entity is not null && !string.IsNullOrEmpty(entity.ParentTaskId)) { var parent = Items.FirstOrDefault(r => r.Id == entity.ParentTaskId); if (parent is not null) parent.HasQueuedSubtasks = Items.Any(r => r.ParentTaskId == parent.Id && (r.IsQueued || r.IsWaiting)); } Regroup(); UpdateSubtitle(); } // NOTE: virtual:queued/virtual:running cannot be decided by a single entity — a Planning // parent matches iff any child has the matching status. OnWorkerTaskUpdated handles those // lists via a full reload rather than the delta path. private static bool TaskMatchesList(TaskEntity t, ListNavItemViewModel list) => list.Kind switch { ListKind.Smart when list.Id == "smart:my-day" => t.IsMyDay, ListKind.Smart when list.Id == "smart:important" => t.IsStarred, ListKind.Smart when list.Id == "smart:planned" => t.ScheduledFor != null, ListKind.Virtual when list.Id == "virtual:review" => t.Status == TaskStatus.WaitingForReview, ListKind.User => $"user:{t.ListId}" == list.Id, _ => false, }; private void OnCurrentListPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { if (sender is not ListNavItemViewModel vm) return; if (e.PropertyName == nameof(ListNavItemViewModel.Name)) HeaderTitle = vm.Name; else if (e.PropertyName == nameof(ListNavItemViewModel.WorkingDir)) { IsLetClaudeVisible = vm.Kind == ListKind.User && !string.IsNullOrWhiteSpace(vm.WorkingDir); IsQuickClaudeVisible = IsLetClaudeVisible; } } public void LoadForList(ListNavItemViewModel? list) { _loadCts?.Cancel(); _loadCts?.Dispose(); _loadCts = new CancellationTokenSource(); var ct = _loadCts.Token; // Items is rebuilt from scratch below, so a selection carried over from the previous list // would leave the detail pane bound to a task the visible list no longer contains. Only a // *different* list drops it — a reload of the same list (worker refresh, reconnect) keeps // the selection so a live update never yanks the detail pane away. var listChanged = !string.Equals(_currentList?.Id, list?.Id, StringComparison.Ordinal); // A same-list reload (triggered by OnWorkerTaskUpdated's full-reload branches) rebuilds // every row from scratch. Reusing the previous instances by id — instead of handing back // brand new ones — keeps SelectedTask (and the bound DetailsIslandViewModel.Task) pointed // at a live row instead of an orphan that never receives another update. var reusable = listChanged ? null : Items.ToDictionary(r => r.Id); if (_currentList is not null) _currentList.PropertyChanged -= OnCurrentListPropertyChanged; _currentList = list; if (_currentList is not null) _currentList.PropertyChanged += OnCurrentListPropertyChanged; Items.Clear(); Rows.Clear(); HasOverdue = false; HasOpen = false; HasCompleted = false; ShowOpenLabel = false; ShowNotesRow = false; if (listChanged) SelectFrom(null, "list-change"); if (list is null) { IsLetClaudeVisible = false; IsQuickClaudeVisible = false; LoadTask = Task.CompletedTask; return; } HeaderTitle = list.Name; HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant(); ShowNotesRow = list.Id == "smart:my-day"; IsMyDayList = list.Id == "smart:my-day"; IsLetClaudeVisible = list.Kind == ListKind.User && !string.IsNullOrWhiteSpace(list.WorkingDir); IsQuickClaudeVisible = IsLetClaudeVisible; LoadTask = LoadForListAsync(list, ct, reusable); } private async Task LoadForListAsync( ListNavItemViewModel list, CancellationToken ct, Dictionary? 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() : 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(); 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 Overdue, List Open, List 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(); var emitted = new HashSet(); 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(); var open = new List(); var completed = new List(); foreach (var r in flat) { var underOpenPlanningParent = r.IsChild && flat.Any(p => p.Id == r.ParentTaskId && p.IsPlanningParent && !p.Done); if (r.Done && !underOpenPlanningParent) completed.Add(r); else if (r.ScheduledFor is { } d && d.Date < today) overdue.Add(r); else open.Add(r); } return (overdue, open, completed); } // ClassifyItems layers the chain-grouping mutation on top of PartitionItems, exactly as // Regroup renders it — every row in the result has ShowAsChainMember/ChainStep/ChainAfterLabel // written. Only call this from a path that actually renders the result; a caller that just // needs section membership (e.g. a count) should call PartitionItems instead. private (List Overdue, List Open, List 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 rowsById) { var current = row; var step = 0; var visited = new HashSet { 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 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 ApplyChainGrouping( List section, Dictionary rowsById) { var sectionIds = section.Select(r => r.Id).ToHashSet(); var headIdOf = new Dictionary(); var stepOf = new Dictionary(); 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(section.Count); var consumed = new HashSet(); 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); } /// Replaces the set of tasks that currently have an open interactive (ConPTY) session, /// so their lifecycle chip reads "Interactive" instead of "Parked". public void SyncInteractiveSessions(IEnumerable 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; /// Set by the shell (mirrors ) so a /// cross-repo move can confirm via the shared modal seam. 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(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( System.Collections.ObjectModel.ObservableCollection 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( System.Collections.ObjectModel.ObservableCollection dst, List target) { var keep = new HashSet(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; } /// /// Drag-drop move: reassigns a task (and every descendant) to . /// 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. /// 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 { row.Id }; ownIds.AddRange(descendantIds); var hasBlockingWorktree = await db.Worktrees.AsNoTracking() .Where(w => ownIds.Contains(w.TaskId) && (w.State == ClaudeDo.Data.Models.WorktreeState.Active || w.State == ClaudeDo.Data.Models.WorktreeState.Kept)) .AnyAsync(); if (hasBlockingWorktree) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.moveWorktreeRejected")); return; } var sourceDir = await db.Lists.AsNoTracking() .Where(l => l.Id == sourceListId).Select(l => l.WorkingDir).FirstOrDefaultAsync(); var targetDir = targetList.WorkingDir; var repoChanges = !string.Equals(sourceDir, targetDir, StringComparison.OrdinalIgnoreCase); if (repoChanges) { if (Dialogs is null) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.moveConfirmUnavailable")); return; } var ok = await Dialogs.ConfirmAsync(Loc.T("vm.tasksIsland.moveRepoConfirm", string.IsNullOrWhiteSpace(sourceDir) ? "—" : sourceDir, string.IsNullOrWhiteSpace(targetDir) ? "—" : targetDir)); if (!ok) return; } await repo.MoveToListAsync(row.Id, targetListId); Items.Remove(row); Regroup(); UpdateSubtitle(); TasksChanged?.Invoke(this, EventArgs.Empty); } [RelayCommand] private async Task ToggleDoneAsync(TaskRowViewModel row) { row.Done = !row.Done; await using var db = await _dbFactory.CreateDbContextAsync(); var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id); if (entity != null) { entity.Status = row.Done ? TaskStatus.Done : TaskStatus.Idle; row.Status = entity.Status; await db.SaveChangesAsync(); } Regroup(); UpdateSubtitle(); TasksChanged?.Invoke(this, EventArgs.Empty); } [RelayCommand] private async Task ClearCompletedAsync() { var (_, _, completed) = PartitionItems(); if (completed.Count == 0) return; // Delete children before parents so the parent-child FK (Restrict) doesn't // block removing a completed planning parent together with its done children. var toDelete = completed.OrderByDescending(r => r.IsChild).ToList(); if (ConfirmAsync is not null) { var ok = await ConfirmAsync($"Clear {toDelete.Count} completed task(s)? This cannot be undone."); if (!ok) return; } var sw = System.Diagnostics.Stopwatch.StartNew(); try { await using var db = await _dbFactory.CreateDbContextAsync(); var repo = new TaskRepository(db); foreach (var row in toDelete) { try { await repo.DeleteAsync(row.Id); Items.Remove(row); } catch { /* still referenced by open child tasks; leave it visible */ } } } finally { OperationTiming.Shared.Record("db", "TasksIsland.ClearCompletedAsync", sw.Elapsed, ok: true); } Regroup(); UpdateSubtitle(); TasksChanged?.Invoke(this, EventArgs.Empty); } [RelayCommand] private async Task ToggleStarAsync(TaskRowViewModel row) { row.IsStarred = !row.IsStarred; await using var db = await _dbFactory.CreateDbContextAsync(); var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id); if (entity != null) { entity.IsStarred = row.IsStarred; await db.SaveChangesAsync(); } TasksChanged?.Invoke(this, EventArgs.Empty); } /// 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. [RelayCommand] private async Task ToggleManualAsync(TaskRowViewModel? row) { if (row is null) return; row.IsManual = !row.IsManual; await using var db = await _dbFactory.CreateDbContextAsync(); var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id); if (entity != null) { entity.IsManual = row.IsManual; await db.SaveChangesAsync(); } TasksChanged?.Invoke(this, EventArgs.Empty); } [RelayCommand] private async Task AddToMyDayAsync(TaskRowViewModel? row) { if (row is null || row.IsMyDay) return; row.IsMyDay = true; await using var db = await _dbFactory.CreateDbContextAsync(); var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id); if (entity != null) { entity.IsMyDay = true; await db.SaveChangesAsync(); } Regroup(); UpdateSubtitle(); TasksChanged?.Invoke(this, EventArgs.Empty); } [RelayCommand] private async Task RemoveFromMyDayAsync(TaskRowViewModel? row) { if (row is null) return; row.IsMyDay = false; await using var db = await _dbFactory.CreateDbContextAsync(); // Removing a parent takes its whole plan off My Day: clear the task and every child, so no // orphaned child is left behind (independently-IsMyDay children included). A leaf child has // no children of its own, so this collapses to just clearing the row itself. var affected = await db.Tasks .Where(t => t.Id == row.Id || t.ParentTaskId == row.Id) .ToListAsync(); foreach (var t in affected) t.IsMyDay = false; if (affected.Count > 0) await db.SaveChangesAsync(); if (_currentList?.Id == "smart:my-day") { var drop = Items .Where(r => r.Id == row.Id || r.ParentTaskId == row.Id) .ToList(); foreach (var r in drop) Items.Remove(r); } Regroup(); UpdateSubtitle(); TasksChanged?.Invoke(this, EventArgs.Empty); } public async Task SetStatusOnRowAsync(TaskRowViewModel row, TaskStatus status) { if (_worker is null) return; try { var baseDirty = await _worker.SetTaskStatusAsync(row.Id, status); ReportBaseDirty(baseDirty); } catch { /* offline; broadcast won't fire */ } } private void ReportBaseDirty(BaseDirtyWarningDto? warning) { if (warning is null) return; ErrorReported?.Invoke(Loc.T( "vm.queue.baseDirtyWarning", warning.ModifiedCount, warning.UntrackedCount)); } [RelayCommand] private async Task SendToQueueAsync(TaskRowViewModel? row) { if (row is null || row.IsRunning || row.HasInteractiveSession || _worker is null) return; // A finalized planning parent queues its plan (children sequentially), not itself. if (row.CanQueuePlan) { // The hub's QueuePlanningSubtasksAsync queues every Idle child unconditionally — it has // no notion of a UI-hosted ConPTY session. Block the whole plan if any child has one open, // otherwise that child's worktree would get an autonomous run racing the user's own edits. var interactiveChildren = Items .Where(r => r.ParentTaskId == row.Id && r.HasInteractiveSession) .Select(r => r.Title) .ToList(); if (interactiveChildren.Count > 0) { ErrorReported?.Invoke(Loc.T( "vm.tasksIsland.queuePlanBlockedInteractive", string.Join(", ", interactiveChildren))); return; } await QueuePlanningSubtasksAsync(row); return; } // Goes through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write // so the manual/draft-child guards apply here too; the row refreshes from the resulting // TaskUpdated broadcast. try { var baseDirty = await _worker.SetTaskStatusAsync(row.Id, TaskStatus.Queued); ReportBaseDirty(baseDirty); } catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.sendToQueueFailed", ex.Message)); } } [RelayCommand] private async Task RemoveFromQueueAsync(TaskRowViewModel? row) { if (row is null) return; await using var db = await _dbFactory.CreateDbContextAsync(); var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id); if (entity is null) return; // Cascade to queued children when present — covers both planning parents // (PlanningPhase != None) and bare parents that have a manually-queued // chain. The X button's visibility is gated by the same condition // (HasQueuedSubtasks), so the handler matches what the user can see. var queuedChildren = await db.Tasks .Where(t => t.ParentTaskId == row.Id && t.Status == TaskStatus.Queued) .ToListAsync(); foreach (var c in queuedChildren) { c.Status = TaskStatus.Idle; c.BlockedByTaskId = null; } if (entity.Status == TaskStatus.Queued) entity.Status = TaskStatus.Idle; await db.SaveChangesAsync(); foreach (var c in queuedChildren) { var childRow = Items.FirstOrDefault(r => r.Id == c.Id); if (childRow is not null) { childRow.Status = TaskStatus.Idle; childRow.BlockedByTaskId = null; } } if (row.Status == TaskStatus.Queued) row.Status = TaskStatus.Idle; row.HasQueuedSubtasks = false; Regroup(); UpdateSubtitle(); TasksChanged?.Invoke(this, EventArgs.Empty); } [RelayCommand] private async Task CancelRunningTaskAsync(TaskRowViewModel? row) { if (row is null || !row.IsRunning || _worker is null) return; try { await _worker.CancelTaskAsync(row.Id); } catch { /* worker offline; the broadcast will reconcile when it returns */ } } // ── Review actions (visible when a task is WaitingForReview) ───────────── // Each delegates to the worker hub, which performs the transition and // broadcasts TaskUpdated; the row refreshes from that broadcast. [RelayCommand] private async Task ApproveReviewAsync(TaskRowViewModel? row) { if (row is null || !row.IsWaitingForReview || _worker is null) return; try { await _worker.ApproveReviewAsync(row.Id, ""); } catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.approveFailed", ex.Message)); } } public async Task RejectReviewToQueueAsync(TaskRowViewModel row, string feedback) { if (!row.IsWaitingForReview || _worker is null) return; if (string.IsNullOrWhiteSpace(feedback)) return; try { await _worker.RejectReviewToQueueAsync(row.Id, feedback); } catch { /* offline; broadcast reconciles on return */ } } [RelayCommand] private async Task RejectReviewToIdleAsync(TaskRowViewModel? row) { if (row is null || !row.IsWaitingForReview || _worker is null) return; try { await _worker.RejectReviewToIdleAsync(row.Id); } catch { /* offline; broadcast reconciles on return */ } } [RelayCommand] private async Task CancelReviewAsync(TaskRowViewModel? row) { if (row is null || !row.IsWaitingForReview || _worker is null) return; try { await _worker.CancelReviewAsync(row.Id); } catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.cancelReviewFailed", ex.Message)); } } public async Task SetScheduledForAsync(TaskRowViewModel row, DateTime? when) { if (row is null) return; await using var db = await _dbFactory.CreateDbContextAsync(); var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id); if (entity is null) return; entity.ScheduledFor = when; await db.SaveChangesAsync(); row.ScheduledFor = when; Regroup(); UpdateSubtitle(); TasksChanged?.Invoke(this, EventArgs.Empty); } [RelayCommand] private Task ClearScheduleAsync(TaskRowViewModel? row) => row is null ? Task.CompletedTask : SetScheduledForAsync(row, null); [RelayCommand] private void Select(TaskRowViewModel row) => SelectFrom(row, "row-click"); public async System.Threading.Tasks.Task 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? OpenConPtySessionRequested; // Opens (resume=false) or resumes (resume=true) a planning session as an embedded ConPTY // pane in the Command Center. public event Action? OpenPlanningConPtyRequested; [RelayCommand] private void OpenConPtySession(TaskRowViewModel? row) { if (row is null) return; OpenConPtySessionRequested?.Invoke(row.Id); } [RelayCommand] private async Task ResumePlanningSessionAsync(TaskRowViewModel? row) { if (row is null || !row.IsPlanningParent) return; if (_worker is null) return; try { var draftCount = await _worker.GetPendingDraftCountAsync(row.Id); var modalVm = new UnfinishedPlanningModalViewModel { TaskTitle = row.Title, DraftCount = draftCount, }; if (ShowUnfinishedPlanningModal is null) return; await ShowUnfinishedPlanningModal(modalVm); var choice = await modalVm.Result.Task; switch (choice) { case UnfinishedPlanningModalResult.Resume: // Resume as an embedded ConPTY pane (server-side ResumeAsync runs inside // GetPlanningResumeLaunchSpec when the pane opens). OpenPlanningConPtyRequested?.Invoke(row.Id, true); break; case UnfinishedPlanningModalResult.FinalizeNow: await FinalizePlanningSessionAsync(row); break; case UnfinishedPlanningModalResult.Discard: await TryDiscardPlanningWithRetryAsync(row.Id); break; case UnfinishedPlanningModalResult.Cancel: default: break; } } catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.planningResumeFailed", ex.Message)); } } [RelayCommand] private async Task DiscardPlanningSessionAsync(TaskRowViewModel? row) { if (row is null || _worker is null) return; await TryDiscardPlanningWithRetryAsync(row.Id); } /// /// 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. /// 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."); } } /// /// Wired by the view via . Returns true when the user confirms. /// public Func>? ConfirmAsync { get; set; } private bool CanRunPlanningOperation() => !PlanningOperation.IsRunning; [RelayCommand(CanExecute = nameof(CanRunPlanningOperation))] private async Task QueuePlanningSubtasksAsync(TaskRowViewModel? row) { if (row is null || _worker is null) return; using var op = PlanningOperation.Begin(Loc.T("ops.planning.queuingSubtasks")); try { await _worker.QueuePlanningSubtasksAsync(row.Id); } catch { } } [RelayCommand(CanExecute = nameof(CanRunPlanningOperation))] private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row) { if (row is null) return; using var op = PlanningOperation.Begin(Loc.T("ops.planning.finalizing")); try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false); } catch { } } [RelayCommand] private void ToggleExpand(TaskRowViewModel? row) { if (row is null) return; var next = !(_expandedState.TryGetValue(row.Id, out var current) ? current : row.IsExpanded); _expandedState[row.Id] = next; row.IsExpanded = next; Regroup(); } [RelayCommand] private async Task RefineTask(TaskRowViewModel row) { if (row is null || !row.CanRefine) return; row.IsRefining = true; try { await _worker!.RefineTaskAsync(row.Id); } catch { row.IsRefining = false; } } private void OnRefineStarted(string taskId) { var row = Items.FirstOrDefault(r => r.Id == taskId); if (row is not null) row.IsRefining = true; } private void OnRefineFinished(string taskId, bool ok, string? error) { var row = Items.FirstOrDefault(r => r.Id == taskId); if (row is not null) row.IsRefining = false; } partial void OnSelectedTaskChanged(TaskRowViewModel? value) { foreach (var i in Items) i.IsSelected = ReferenceEquals(i, value); SelectionChanged?.Invoke(this, EventArgs.Empty); } // ── Phase 3: reconcile tick ────────────────────────────────────────────── // Self-healing safety net for a lost broadcast: every few seconds, diff the flat `Items` // master collection against SQLite and patch properties in place. Never rebuilds a row and // never falls back to LoadForList — the delta path already owns that escalation. It DOES // call Regroup(), but only when a patch actually moved a row's grouping inputs: a healed // row can change section, order or rail label, and none of that shows until Rows is // re-emitted. // Above this many rows, only the first N (in Items order) are reconciled per tick, so the // query cost stays bounded instead of growing with an ever-larger list. internal const int ReconcileRowCap = 500; // Set by the shell from the main window's WindowState; the tick no-ops while minimized. public bool IsWindowVisible { get; set; } = true; // Test seam: awaited right after the reconcile query returns and before its results are // applied, so a test can land a fresher, higher-sequence update in that gap and exercise the // stale-sequence guard deterministically. Always null outside tests. internal Func? 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(ids.Count); foreach (var id in ids) { var seq = ++_deltaCounter; seqByTaskId[id] = seq; _deltaSeq[id] = seq; } List 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(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); }