using System.Collections.ObjectModel; using System.Text; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Ui.Helpers; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services.Interfaces; using ClaudeDo.Ui.ViewModels.Modals; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace ClaudeDo.Ui.ViewModels.Islands; public enum LogKind { Sys, Tool, Claude, Stdout, Stderr, Done, Msg } public sealed class LogLineViewModel { public required LogKind Kind { get; init; } public required string Text { get; init; } public string TimestampFormatted { get; } = DateTime.Now.ToString("HH:mm:ss"); public string KindMarker => Kind switch { LogKind.Sys => "sys", LogKind.Tool => "tool", LogKind.Claude => "claude", LogKind.Stdout => "out", LogKind.Stderr => "err", LogKind.Done => "done", LogKind.Msg => "claude", _ => "", }; public string ClassName => Kind switch { LogKind.Sys => "log-sys", LogKind.Tool => "log-tool", LogKind.Claude => "log-claude", LogKind.Stdout => "log-stdout", LogKind.Stderr => "log-stderr", LogKind.Done => "log-done", LogKind.Msg => "log-msg", _ => "", }; } public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable { private readonly IDbContextFactory _dbFactory; private readonly IWorkerClient _worker; private readonly IServiceProvider _services; private readonly INotesApi _notesApi; // Captured handler delegates for disposal private readonly EventHandler _langChangedHandler; private readonly System.ComponentModel.PropertyChangedEventHandler _workerPropertyChangedHandler; private readonly Action _workerTaskStartedHandler; private readonly Action _workerTaskFinishedHandler; private readonly Action _workerWorktreeUpdatedHandler; private readonly Action _workerTaskUpdatedHandler; [ObservableProperty] private bool _isNotesMode; [ObservableProperty] private bool _isPrepMode; [ObservableProperty] private bool _isPrepRunning; public ObservableCollection PrepLog { get; } = new(); public bool IsTaskDetailVisible => !IsNotesMode && !IsPrepMode; partial void OnIsNotesModeChanged(bool value) => OnPropertyChanged(nameof(IsTaskDetailVisible)); partial void OnIsPrepModeChanged(bool value) => OnPropertyChanged(nameof(IsTaskDetailVisible)); public NotesEditorViewModel Notes { get; private set; } = null!; // Current task row (set by IslandsShellViewModel via Bind) [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(EnqueueCommand))] [NotifyCanExecuteChangedFor(nameof(DequeueCommand))] [NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))] [NotifyCanExecuteChangedFor(nameof(ReviewCombinedDiffCommand))] [NotifyPropertyChangedFor(nameof(TaskIdBadge))] private TaskRowViewModel? _task; // Editable fields [ObservableProperty] [NotifyPropertyChangedFor(nameof(ComposedPreview))] private string _editableTitle = ""; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ComposedPreview))] private string _editableDescription = ""; [ObservableProperty] private bool _isEditingDescription; [ObservableProperty] private bool _isDescriptionExpanded = true; public bool IsDescriptionEditorVisible => IsDescriptionExpanded && IsEditingDescription; public bool IsDescriptionPreviewVisible => IsDescriptionExpanded && !IsEditingDescription; partial void OnIsDescriptionExpandedChanged(bool value) { OnPropertyChanged(nameof(IsDescriptionEditorVisible)); OnPropertyChanged(nameof(IsDescriptionPreviewVisible)); } partial void OnIsEditingDescriptionChanged(bool value) { OnPropertyChanged(nameof(IsDescriptionEditorVisible)); OnPropertyChanged(nameof(IsDescriptionPreviewVisible)); } [RelayCommand] private void ToggleEditDescription() => IsEditingDescription = !IsEditingDescription; [RelayCommand] private void ToggleDescriptionExpanded() => IsDescriptionExpanded = !IsDescriptionExpanded; // ── Description / Steps card (redesign) ───────────────────────────── // Description is always the card body; steps live in an expandable summary // strip below it so step presence is visible without switching views. [ObservableProperty] private bool _isStepsExpanded; [RelayCommand] private void ToggleStepsExpanded() => IsStepsExpanded = !IsStepsExpanded; public int TotalStepCount => Subtasks.Count; public int OpenStepCount => Subtasks.Count(s => !s.Done); public string StepsSummary => TotalStepCount == 0 ? "no steps yet" : OpenStepCount == 0 ? $"all done · {TotalStepCount} total" : $"{OpenStepCount} open · {TotalStepCount} total"; private void NotifyStepsChanged() { OnPropertyChanged(nameof(TotalStepCount)); OnPropertyChanged(nameof(OpenStepCount)); OnPropertyChanged(nameof(StepsSummary)); OnPropertyChanged(nameof(ComposedPreview)); } // The exact text handed to Claude: title + description + open steps only. public string ComposedPreview => ClaudeDo.Data.TaskPromptComposer.Compose( EditableTitle, EditableDescription, Subtasks.Select(s => (s.Title, s.Done))); // ── Work console (redesign) ──────────────────────────────────────── // Two tabs: Output (live log) and Session (review + merge/worktree + // outcomes, each section gated on the relevant state). [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsOutputTab))] [NotifyPropertyChangedFor(nameof(IsGitTab))] [NotifyPropertyChangedFor(nameof(IsSessionTab))] private string _selectedTab = "output"; public bool IsOutputTab => SelectedTab == "output"; public bool IsGitTab => SelectedTab == "git"; public bool IsSessionTab => SelectedTab == "session"; [RelayCommand] private void SelectTab(string? tab) => SelectedTab = tab ?? "output"; // Merge/worktree controls only matter once there's a worktree to manage // (standalone task), or a planning parent / improvement parent with children. public bool ShowMergeSection => WorktreePath != null || Task?.IsPlanningParent == true || HasChildOutcomes; private void NotifySessionSections() { OnPropertyChanged(nameof(HasChildOutcomes)); OnPropertyChanged(nameof(ShowMergeSection)); // The Session tab is only visible when it has outcomes; if it just // emptied while selected, fall back to Output so the body isn't blank. if (!HasChildOutcomes && SelectedTab == "session") SelectedTab = "output"; } public string TurnsText => $"{Turns}/{EffectiveMaxTurns}"; public string DiffAddText => $"+{DiffAdditions}"; public string DiffDelText => $"-{DiffDeletions}"; // Resolved turn budget: per-task override → list default → global default. public int EffectiveMaxTurns => TaskMaxTurns is decimal t ? (int)t : (_listMaxTurns ?? _globalMaxTurns); public bool ShowRoadblock => IsFailed || IsCancelled; public string RoadblockMessage => IsFailed ? "The session ended with an error." : IsCancelled ? "The session was cancelled." : ""; // The session's outcome summary — the task's Result minus any roadblock // section (those get their own card), falling back to the run's // ErrorMarkdown for hard failures. Shown once a run has finished. [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowSessionOutcome))] private string? _sessionOutcome; public bool ShowSessionOutcome => !string.IsNullOrWhiteSpace(SessionOutcome) && (IsWaitingForReview || IsDone || IsFailed || IsCancelled); // The roadblocks the agent emitted (CLAUDEDO_BLOCKED), parsed out of the // run result so they can surface as a distinct colored card. [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowRoadblockCard))] private string? _roadblocks; public bool ShowRoadblockCard => !string.IsNullOrWhiteSpace(Roadblocks) && (IsWaitingForReview || IsDone || IsFailed || IsCancelled); // Worker writes roadblocks into the result under this header // (TaskRunner.ComposeReviewResult). Split it back out for display. private const string RoadblockMarker = "Roadblocks reported during the run:"; private void ApplyOutcome(string? result, string? errorFallback) { if (string.IsNullOrWhiteSpace(result)) { SessionOutcome = errorFallback; Roadblocks = null; return; } var idx = result.IndexOf(RoadblockMarker, StringComparison.Ordinal); if (idx < 0) { SessionOutcome = result; Roadblocks = null; return; } var summary = result[..idx].TrimEnd().TrimEnd('⚠').TrimEnd(); SessionOutcome = string.IsNullOrWhiteSpace(summary) ? null : summary; Roadblocks = result[(idx + RoadblockMarker.Length)..].Trim(); } public string SessionLabel => "claude-session"; // Short task-id badge, e.g. "#T1A" public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : ""; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(EnqueueCommand))] [NotifyCanExecuteChangedFor(nameof(DequeueCommand))] [NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))] [NotifyCanExecuteChangedFor(nameof(ContinueCommand))] private string _agentState = "idle"; public string AgentStatusLabel => Loc.T($"vm.agentStatus.{AgentState}"); public bool IsIdle => AgentState == "idle"; public bool IsQueued => AgentState == "queued"; public bool IsRunning => AgentState == "running"; public bool IsWaitingForReview => AgentState == "review"; public bool IsWaitingForChildren => AgentState == "children"; public bool IsDone => AgentState == "done"; public bool IsFailed => AgentState == "failed"; public bool IsCancelled => AgentState == "cancelled"; // Recovery actions: Continue (resume session) for Failed/Cancelled. public bool ShowContinue => IsFailed || IsCancelled; // Reset & retry available from any terminal state. public bool ShowResetAndRetry => IsFailed || IsCancelled || IsDone; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ContinueCommand))] private string? _latestRunSessionId; partial void OnAgentStateChanged(string value) { OnPropertyChanged(nameof(AgentStatusLabel)); OnPropertyChanged(nameof(IsIdle)); OnPropertyChanged(nameof(IsQueued)); OnPropertyChanged(nameof(IsRunning)); OnPropertyChanged(nameof(IsWaitingForReview)); OnPropertyChanged(nameof(IsWaitingForChildren)); OnPropertyChanged(nameof(IsDone)); OnPropertyChanged(nameof(IsFailed)); OnPropertyChanged(nameof(IsCancelled)); OnPropertyChanged(nameof(ShowContinue)); OnPropertyChanged(nameof(ShowResetAndRetry)); OnPropertyChanged(nameof(IsAgentSectionEnabled)); OnPropertyChanged(nameof(ShowRoadblock)); OnPropertyChanged(nameof(RoadblockMessage)); OnPropertyChanged(nameof(ShowSessionOutcome)); OnPropertyChanged(nameof(ShowRoadblockCard)); NotifySessionSections(); } [ObservableProperty] private string? _model; // Agent settings overrides [ObservableProperty] private string? _taskModelSelection; // null = inherit [ObservableProperty] private string _taskSystemPrompt = ""; [ObservableProperty] private AgentInfo? _taskSelectedAgent; [ObservableProperty] private decimal? _taskMaxTurns; // null = inherit [ObservableProperty] private string _modelBadge = ""; [ObservableProperty] private string _modelInheritedHint = ""; [ObservableProperty] private string _turnsBadge = ""; [ObservableProperty] private string _turnsInheritedHint = ""; [ObservableProperty] private string _agentBadge = ""; [ObservableProperty] private string _effectiveSystemPromptHint = ""; private string _globalModel = ModelRegistry.DefaultAlias; private int _globalMaxTurns = 100; private string? _listModel; private int? _listMaxTurns; private string? _listAgentName; partial void OnTaskModelSelectionChanged(string? value) { RecomputeModelBadge(); QueueAgentSave(); } partial void OnTaskMaxTurnsChanged(decimal? value) { RecomputeTurnsBadge(); OnPropertyChanged(nameof(EffectiveMaxTurns)); OnPropertyChanged(nameof(TurnsText)); QueueAgentSave(); } private void RecomputeModelBadge() { var (value, source) = InheritanceResolver.Resolve(TaskModelSelection, _listModel, _globalModel); ModelInheritedHint = value; ModelBadge = BadgeFor(source, !string.IsNullOrWhiteSpace(TaskModelSelection)); } private void RecomputeTurnsBadge() { var (value, source) = InheritanceResolver.Resolve( TaskMaxTurns?.ToString(), _listMaxTurns?.ToString(), _globalMaxTurns.ToString()); TurnsInheritedHint = value; TurnsBadge = BadgeFor(source, TaskMaxTurns is not null); } private void RecomputeAgentBadge() { var taskSet = TaskSelectedAgent is not null && !string.IsNullOrWhiteSpace(TaskSelectedAgent.Path); var (_, source) = InheritanceResolver.Resolve( taskSet ? TaskSelectedAgent!.Path : null, _listAgentName, null); AgentBadge = BadgeFor(source, taskSet); } private static string BadgeFor(InheritSource source, bool taskSet) => taskSet ? Loc.T("settings.inherit.overrideBadge") : source == InheritSource.List ? Loc.T("settings.inherit.inheritedFromList") : Loc.T("settings.inherit.inheritedFromGlobal"); public System.Collections.ObjectModel.ObservableCollection TaskModelOptions { get; } = new(ModelRegistry.Aliases); public System.Collections.ObjectModel.ObservableCollection TaskAgentOptions { get; } = new(); private bool _suppressAgentSave; private CancellationTokenSource? _agentSaveCts; private bool _suppressDescSave; private CancellationTokenSource? _descSaveCts; public bool IsAgentSectionEnabled => !IsRunning; [ObservableProperty] private string? _worktreePath; [ObservableProperty] private string? _worktreeBaseCommit; [ObservableProperty] private string? _worktreeStateLabel; [ObservableProperty] private string? _branchLine; [ObservableProperty] private int _turns; [ObservableProperty] private int _tokens; [ObservableProperty] private int _diffAdditions; [ObservableProperty] private int _diffDeletions; [ObservableProperty] private int _commitsOnBranch; public string TokensFormatted => Tokens >= 1000 ? $"{Tokens / 1000.0:F1}k" : Tokens.ToString(); public string ElapsedFormatted => ""; // placeholder — no start-time stored yet partial void OnTokensChanged(int value) => OnPropertyChanged(nameof(TokensFormatted)); partial void OnTurnsChanged(int value) => OnPropertyChanged(nameof(TurnsText)); partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(DiffMeterRatio)); OnPropertyChanged(nameof(DiffAddText)); } partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(DiffMeterRatio)); OnPropertyChanged(nameof(DiffDelText)); } // 0.0–1.0 additions share for the diff meter public double DiffMeterRatio { get { var total = DiffAdditions + DiffDeletions; return total == 0 ? 0.0 : (double)DiffAdditions / total; } } public ObservableCollection Log { get; } = new(); public ObservableCollection Subtasks { get; } = new(); // Agent-suggested improvement children of a non-planning parent, surfaced on its // review card with each child's outcome and rolled-up roadblock count. public ObservableCollection ChildOutcomes { get; } = new(); public bool HasChildOutcomes => ChildOutcomes.Count > 0; [ObservableProperty] private string _newSubtaskTitle = ""; // Planning merge controls [ObservableProperty] private ObservableCollection _mergeTargetBranches = new(); [ObservableProperty] private string? _selectedMergeTarget; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))] private string _mergePreviewText = ""; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))] private bool _mergeIsClean; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))] private bool _mergeIsConflict; public bool ShowMergePreviewMuted => !MergeIsClean && !MergeIsConflict && !string.IsNullOrEmpty(MergePreviewText); // Claude CLI stream-json parser + buffer for partial text deltas private readonly StreamLineFormatter _formatter = new(); private readonly StringBuilder _claudeBuf = new(); private readonly StringBuilder _prepClaudeBuf = new(); // The task ID we are currently subscribed to for live log messages private string? _subscribedTaskId; private CancellationTokenSource? _loadCts; // Set by shell so CloseDetailCommand can clear SelectedTask public Action? CloseDetail { get; set; } // Set by shell so DeleteTaskCommand can remove from list public Func? DeleteFromList { get; set; } // Set by the view so OpenDiffCommand can show the modal as a dialog public Func? ShowDiffModal { get; set; } // Set by the view so OpenDiff can pass through merge requests from the diff modal public Func? ShowMergeModal { get; set; } // Set by the view so DeleteTaskCommand can prompt yes/no before deleting public Func>? ConfirmAsync { get; set; } // Set by the view so ReviewCombinedDiffCommand can show the planning diff modal public Func? ShowPlanningDiffModal { get; set; } // Set by the view so DeleteTaskCommand can show an error message public Func? ShowErrorAsync { get; set; } // Invoked when a single-task merge/approve hits a conflict. Wired by the // integrator to Layer C's conflict resolver. Args: (taskId, targetBranch). public Func? RequestConflictResolution { get; set; } private static string StatusToStateKey(ClaudeDo.Data.Models.TaskStatus status) => status switch { ClaudeDo.Data.Models.TaskStatus.Queued => "queued", ClaudeDo.Data.Models.TaskStatus.Running => "running", ClaudeDo.Data.Models.TaskStatus.WaitingForReview => "review", ClaudeDo.Data.Models.TaskStatus.WaitingForChildren => "children", ClaudeDo.Data.Models.TaskStatus.Done => "done", ClaudeDo.Data.Models.TaskStatus.Failed => "failed", ClaudeDo.Data.Models.TaskStatus.Cancelled => "cancelled", _ => "idle", }; private static string FinishedStatusToStateKey(string status) => status switch { "done" => "done", "failed" => "failed", "cancelled" => "cancelled", "waiting_for_review" => "review", "waiting_for_children" => "children", _ => status.ToLowerInvariant(), }; private async System.Threading.Tasks.Task RefreshStatusAsync(string taskId) { try { await using var ctx = await _dbFactory.CreateDbContextAsync(); var entity = await ctx.Tasks .AsNoTracking() .FirstOrDefaultAsync(t => t.Id == taskId); if (entity is null || Task?.Id != taskId) return; AgentState = StatusToStateKey(entity.Status); } catch { } } // Reload the session outcome (task Result incl. roadblocks, or the run's // error) so it appears as soon as a run finishes. private async System.Threading.Tasks.Task RefreshOutcomeAsync(string taskId) { try { await using var ctx = await _dbFactory.CreateDbContextAsync(); var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId); var latestRun = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId); if (Task?.Id != taskId) return; ApplyOutcome(entity?.Result, latestRun?.ErrorMarkdown); } catch { } } public DetailsIslandViewModel(IDbContextFactory dbFactory, IWorkerClient worker, IServiceProvider services, INotesApi notesApi) { _dbFactory = dbFactory; _worker = worker; _services = services; _notesApi = notesApi; Notes = new NotesEditorViewModel(_notesApi); Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged(); _langChangedHandler = (_, _) => { OnPropertyChanged(nameof(AgentStatusLabel)); RecomputeModelBadge(); RecomputeTurnsBadge(); RecomputeAgentBadge(); }; Loc.LanguageChanged += _langChangedHandler; // Subscribe once; filter by current task id inside the handler _worker.TaskMessageEvent += OnTaskMessage; _worker.PrepStartedEvent += OnPrepStarted; _worker.PrepLineEvent += OnPrepLine; _worker.PrepFinishedEvent += OnPrepFinished; // Re-evaluate CanExecute when worker connection flips. _workerPropertyChangedHandler = (_, e) => { if (e.PropertyName == nameof(WorkerClient.IsConnected)) { EnqueueCommand.NotifyCanExecuteChanged(); DequeueCommand.NotifyCanExecuteChanged(); ResetAndRetryCommand.NotifyCanExecuteChanged(); ContinueCommand.NotifyCanExecuteChanged(); } }; _worker.PropertyChanged += _workerPropertyChangedHandler; // If the task row's live status changes (e.g. TaskStarted/Finished), mirror it. _workerTaskStartedHandler = (slot, taskId, startedAt) => { if (Task?.Id == taskId) AgentState = "running"; _ = RefreshChildOutcomeAsync(taskId); }; _worker.TaskStartedEvent += _workerTaskStartedHandler; _workerTaskFinishedHandler = (slot, taskId, status, finishedAt) => { if (Task?.Id != taskId) return; FlushClaudeBuffer(); Log.Add(new LogLineViewModel { Kind = LogKind.Done, Text = $"── {status.ToUpperInvariant()} · {finishedAt.ToLocalTime():HH:mm:ss} ──", }); AgentState = FinishedStatusToStateKey(status); // Re-query to pick up worktree created during the run. _ = RefreshWorktreeAsync(taskId); _ = RefreshChildOutcomeAsync(taskId); _ = RefreshOutcomeAsync(taskId); }; _worker.TaskFinishedEvent += _workerTaskFinishedHandler; _workerWorktreeUpdatedHandler = taskId => { if (Task?.Id == taskId) _ = RefreshWorktreeAsync(taskId); if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId); _ = RefreshChildOutcomeAsync(taskId); }; _worker.WorktreeUpdatedEvent += _workerWorktreeUpdatedHandler; _workerTaskUpdatedHandler = taskId => { if (Task?.Id == taskId) _ = RefreshStatusAsync(taskId); if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId); _ = RefreshChildOutcomeAsync(taskId); }; _worker.TaskUpdatedEvent += _workerTaskUpdatedHandler; Subtasks.CollectionChanged += (_, _) => { ReviewCombinedDiffCommand.NotifyCanExecuteChanged(); }; ChildOutcomes.CollectionChanged += (_, _) => { ReviewCombinedDiffCommand.NotifyCanExecuteChanged(); NotifySessionSections(); }; PrepLog.CollectionChanged += (_, _) => OnPropertyChanged(nameof(ShowPrepEmptyState)); } public void Dispose() { Loc.LanguageChanged -= _langChangedHandler; _worker.PropertyChanged -= _workerPropertyChangedHandler; _worker.TaskStartedEvent -= _workerTaskStartedHandler; _worker.TaskFinishedEvent -= _workerTaskFinishedHandler; _worker.WorktreeUpdatedEvent -= _workerWorktreeUpdatedHandler; _worker.TaskUpdatedEvent -= _workerTaskUpdatedHandler; _worker.TaskMessageEvent -= OnTaskMessage; _worker.PrepStartedEvent -= OnPrepStarted; _worker.PrepLineEvent -= OnPrepLine; _worker.PrepFinishedEvent -= OnPrepFinished; } private void OnTaskMessage(string taskId, string line) { if (taskId != _subscribedTaskId) return; // `[stdout] ...json...` lines are Claude CLI stream-json; parse through the // formatter so the user sees human text, not raw JSON. if (line.StartsWith("[stdout]", StringComparison.OrdinalIgnoreCase)) { var body = line["[stdout]".Length..].TrimStart(); AppendStdoutLine(Log, body); return; } // Non-stdout tagged lines: flush any buffered text then classify by prefix. FlushClaudeBuffer(); var kind = line.StartsWith("[sys]", StringComparison.OrdinalIgnoreCase) ? LogKind.Sys : line.StartsWith("[tool]", StringComparison.OrdinalIgnoreCase) ? LogKind.Tool : line.StartsWith("[claude]", StringComparison.OrdinalIgnoreCase) ? LogKind.Claude : line.StartsWith("[stderr]", StringComparison.OrdinalIgnoreCase) ? LogKind.Stderr : line.StartsWith("[done]", StringComparison.OrdinalIgnoreCase) ? LogKind.Done : LogKind.Msg; Log.Add(new LogLineViewModel { Kind = kind, Text = line }); } private void AppendStdoutLine(ObservableCollection target, string line) { var formatted = _formatter.FormatLine(line); if (formatted is null) return; var buf = ReferenceEquals(target, Log) ? _claudeBuf : _prepClaudeBuf; AppendClaudeText(formatted, target, buf); } [RelayCommand] private async Task PlanDayAsync() { if (_worker is null) return; try { await _worker.RunDailyPrepNowAsync(); } catch { /* worker offline; PrepStarted/PrepLine will reconcile */ } } public bool ShowPrepEmptyState => !IsPrepRunning && PrepLog.Count == 0; partial void OnIsPrepRunningChanged(bool value) => OnPropertyChanged(nameof(ShowPrepEmptyState)); private void OnPrepStarted() { PrepLog.Clear(); IsPrepRunning = true; } private void OnPrepLine(string line) => AppendStdoutLine(PrepLog, line); private void OnPrepFinished(bool success) => IsPrepRunning = false; private void AppendClaudeText(string chunk) => AppendClaudeText(chunk, Log, _claudeBuf); private static void AppendClaudeText(string chunk, ObservableCollection target, StringBuilder buf) { buf.Append(chunk); // Emit a log entry for every completed line; keep the trailing remainder buffered. while (true) { var text = buf.ToString(); var nl = text.IndexOf('\n'); if (nl < 0) break; var piece = text[..nl].TrimEnd('\r'); if (!string.IsNullOrWhiteSpace(piece)) target.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece }); buf.Clear(); buf.Append(text[(nl + 1)..]); } } private void FlushClaudeBuffer() { if (_claudeBuf.Length == 0) return; var piece = _claudeBuf.ToString().TrimEnd(); _claudeBuf.Clear(); if (!string.IsNullOrWhiteSpace(piece)) Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece }); } partial void OnTaskSystemPromptChanged(string value) => QueueAgentSave(); partial void OnTaskSelectedAgentChanged(AgentInfo? value) { RecomputeAgentBadge(); QueueAgentSave(); } partial void OnEditableDescriptionChanged(string value) { if (_suppressDescSave || Task is null) return; _descSaveCts?.Cancel(); _descSaveCts = new CancellationTokenSource(); _ = SaveDescriptionAsync(_descSaveCts.Token); } private async System.Threading.Tasks.Task SaveDescriptionAsync(CancellationToken ct) { try { await System.Threading.Tasks.Task.Delay(400, ct); if (Task is null) return; await using var ctx = _dbFactory.CreateDbContext(); var repo = new TaskRepository(ctx); var entity = await repo.GetByIdAsync(Task.Id); if (entity is null) return; entity.Description = string.IsNullOrWhiteSpace(EditableDescription) ? null : EditableDescription; await repo.UpdateAsync(entity); } catch (OperationCanceledException) { } catch { } } private void QueueAgentSave() { if (_suppressAgentSave || Task is null) return; _agentSaveCts?.Cancel(); _agentSaveCts = new CancellationTokenSource(); var ct = _agentSaveCts.Token; _ = SaveAgentSettingsAsync(ct); } private async System.Threading.Tasks.Task SaveAgentSettingsAsync(CancellationToken ct) { try { await System.Threading.Tasks.Task.Delay(300, ct); if (Task is null) return; var model = string.IsNullOrWhiteSpace(TaskModelSelection) ? null : TaskModelSelection; var sp = string.IsNullOrWhiteSpace(TaskSystemPrompt) ? null : TaskSystemPrompt; var ap = TaskSelectedAgent is null || string.IsNullOrWhiteSpace(TaskSelectedAgent.Path) ? null : TaskSelectedAgent.Path; var turns = TaskMaxTurns is decimal d ? (int?)d : null; await _worker.UpdateTaskAgentSettingsAsync( new ClaudeDo.Ui.Services.UpdateTaskAgentSettingsDto(Task.Id, model, sp, ap, turns)); } catch (OperationCanceledException) { } catch { } } private async System.Threading.Tasks.Task LoadAgentSettingsAsync( ClaudeDo.Data.Models.TaskEntity entity, CancellationToken ct) { _suppressAgentSave = true; try { TaskAgentOptions.Clear(); TaskAgentOptions.Add(new AgentInfo("(inherited)", "", "")); var agents = await _worker.GetAgentsAsync(); foreach (var a in agents) TaskAgentOptions.Add(a); TaskModelSelection = string.IsNullOrWhiteSpace(entity.Model) ? null : entity.Model!; TaskMaxTurns = entity.MaxTurns is int tmt ? tmt : (decimal?)null; TaskSystemPrompt = entity.SystemPrompt ?? ""; TaskSelectedAgent = string.IsNullOrWhiteSpace(entity.AgentPath) ? TaskAgentOptions[0] : (TaskAgentOptions.FirstOrDefault(a => a.Path == entity.AgentPath) ?? TaskAgentOptions[0]); var listCfg = await _worker.GetListConfigAsync(entity.ListId); var app = await _worker.GetAppSettingsAsync(); _globalModel = app?.DefaultModel ?? ModelRegistry.DefaultAlias; _globalMaxTurns = app?.DefaultMaxTurns ?? 100; _listModel = listCfg?.Model; _listMaxTurns = listCfg?.MaxTurns; _listAgentName = string.IsNullOrWhiteSpace(listCfg?.AgentPath) ? null : System.IO.Path.GetFileName(listCfg!.AgentPath!); EffectiveSystemPromptHint = string.IsNullOrWhiteSpace(listCfg?.SystemPrompt) ? "" : listCfg!.SystemPrompt!; RecomputeModelBadge(); RecomputeTurnsBadge(); RecomputeAgentBadge(); OnPropertyChanged(nameof(EffectiveMaxTurns)); OnPropertyChanged(nameof(TurnsText)); } finally { _suppressAgentSave = false; } } public void ShowNotes() { Bind(null); IsPrepMode = false; IsNotesMode = true; _ = Notes.LoadDayAsync(DateOnly.FromDateTime(DateTime.Today)); } public void ShowPrep() { Bind(null); IsNotesMode = false; IsPrepMode = true; _ = LoadLastPrepLogIfEmptyAsync(); } public async Task LoadLastPrepLogIfEmptyAsync() { if (_worker is null || IsPrepRunning || PrepLog.Count > 0) return; string text; try { text = await _worker.GetLastPrepLogAsync(); } catch { return; } if (IsPrepRunning || PrepLog.Count > 0) return; foreach (var line in text.Split('\n')) { var trimmed = line.TrimEnd('\r'); if (trimmed.Length > 0) AppendStdoutLine(PrepLog, trimmed); } } public void Bind(TaskRowViewModel? row) { IsNotesMode = false; IsPrepMode = false; _loadCts?.Cancel(); _loadCts?.Dispose(); _loadCts = new CancellationTokenSource(); var ct = _loadCts.Token; Task = row; OnPropertyChanged(nameof(TaskIdBadge)); Log.Clear(); Subtasks.Clear(); ChildOutcomes.Clear(); OnPropertyChanged(nameof(HasChildOutcomes)); MergeTargetBranches.Clear(); SelectedMergeTarget = null; SessionOutcome = null; Roadblocks = null; _claudeBuf.Clear(); if (row == null) { _subscribedTaskId = null; EditableTitle = ""; EditableDescription = ""; Model = null; WorktreePath = null; WorktreeStateLabel = null; BranchLine = null; DiffAdditions = 0; DiffDeletions = 0; AgentState = "idle"; LatestRunSessionId = null; _suppressAgentSave = true; try { TaskModelSelection = null; TaskMaxTurns = null; TaskSystemPrompt = ""; TaskSelectedAgent = null; } finally { _suppressAgentSave = false; } EffectiveSystemPromptHint = ""; return; } _ = BindAsync(row, ct); } private async System.Threading.Tasks.Task BindAsync(TaskRowViewModel row, CancellationToken ct) { try { await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var subtaskRepo = new SubtaskRepository(ctx); // Own query with Include so WorktreePath/BranchLine are populated. var entity = await ctx.Tasks .AsNoTracking() .Include(t => t.Worktree) .FirstOrDefaultAsync(t => t.Id == row.Id, ct); ct.ThrowIfCancellationRequested(); if (entity == null) return; EditableTitle = entity.Title; _suppressDescSave = true; try { EditableDescription = entity.Description ?? ""; } finally { _suppressDescSave = false; } Model = entity.Model; WorktreePath = entity.Worktree?.Path; WorktreeBaseCommit = entity.Worktree?.BaseCommit; WorktreeStateLabel = entity.Worktree?.State.ToString(); BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null; var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat); DiffAdditions = add; DiffDeletions = del; AgentState = StatusToStateKey(entity.Status); await LoadAgentSettingsAsync(entity, ct); ct.ThrowIfCancellationRequested(); var runRepo = new TaskRunRepository(ctx); var latestRun = await runRepo.GetLatestByTaskIdAsync(row.Id, ct); ct.ThrowIfCancellationRequested(); LatestRunSessionId = latestRun?.SessionId; ApplyOutcome(entity.Result, latestRun?.ErrorMarkdown); // Subscribe only after DB load confirms the task exists _subscribedTaskId = row.Id; // Replay the latest run's persisted log so output is visible across app restarts. await ReplayLogFileAsync(entity.LogPath, ct); var subs = await subtaskRepo.GetByTaskIdAsync(row.Id); ct.ThrowIfCancellationRequested(); foreach (var s in subs) Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed }); if (entity.PlanningPhase != ClaudeDo.Data.Models.PlanningPhase.None) { await LoadPlanningChildrenAsync(row.Id, ct); } else { await LoadChildOutcomesAsync(row.Id, ct); } if (entity.Worktree != null && entity.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.None && MergeTargetBranches.Count == 0) { var targets = await _worker.GetMergeTargetsAsync(row.Id); if (targets != null) { MergeTargetBranches.Clear(); foreach (var b in targets.LocalBranches) MergeTargetBranches.Add(b); SelectedMergeTarget = targets.DefaultBranch; // triggers OnSelectedMergeTargetChanged → preview } } await RefreshMergePreviewAsync(); } catch (OperationCanceledException) { } } // Improvement parents (non-planning) surface their children's outcomes + roadblocks // on the review card, and reuse the planning merge controls to fold the tree in. private async System.Threading.Tasks.Task LoadChildOutcomesAsync(string parentTaskId, CancellationToken ct) { try { await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var children = await ctx.Tasks .AsNoTracking() .Include(t => t.Worktree) .Where(t => t.ParentTaskId == parentTaskId) .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .ToListAsync(ct); ct.ThrowIfCancellationRequested(); if (children.Count == 0) return; ChildOutcomes.Clear(); foreach (var c in children) ChildOutcomes.Add(new ChildOutcomeRowViewModel { Id = c.Id, Title = c.Title, Status = c.Status, RoadblockCount = c.RoadblockCount, WorktreeState = c.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active, }); OnPropertyChanged(nameof(HasChildOutcomes)); if (MergeTargetBranches.Count == 0) { var childWithWorktree = children.FirstOrDefault(c => c.Worktree != null); if (childWithWorktree != null) { var targets = await _worker.GetMergeTargetsAsync(childWithWorktree.Id); if (targets != null) { MergeTargetBranches.Clear(); foreach (var b in targets.LocalBranches) MergeTargetBranches.Add(b); SelectedMergeTarget = targets.DefaultBranch; } } } } catch (OperationCanceledException) { } catch { /* best-effort */ } } private async System.Threading.Tasks.Task ReplayLogFileAsync(string? logPath, CancellationToken ct) { if (string.IsNullOrWhiteSpace(logPath)) return; var expanded = ExpandUserPath(logPath); if (!System.IO.File.Exists(expanded)) return; try { const int maxLines = 2000; string[] all; await using (var fs = new System.IO.FileStream( expanded, System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.ReadWrite | System.IO.FileShare.Delete)) using (var reader = new System.IO.StreamReader(fs)) { var list = new List(); while (await reader.ReadLineAsync(ct) is { } line) list.Add(line); all = list.ToArray(); } ct.ThrowIfCancellationRequested(); var start = Math.Max(0, all.Length - maxLines); for (int i = start; i < all.Length; i++) { ct.ThrowIfCancellationRequested(); if (_subscribedTaskId is null) return; // Worker writes raw Claude CLI stdout to disk (no prefix) but broadcasts // it with a "[stdout] " prefix. Match the live-stream format so the same // stream-json parser handles both. var line = all[i]; var normalized = line.StartsWith("[", StringComparison.Ordinal) ? line : "[stdout] " + line; OnTaskMessage(_subscribedTaskId, normalized); } FlushClaudeBuffer(); } catch (OperationCanceledException) { throw; } catch { /* best-effort replay */ } } private static string ExpandUserPath(string path) { if (path.StartsWith("~/", StringComparison.Ordinal) || path.StartsWith("~\\", StringComparison.Ordinal)) return System.IO.Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), path[2..]); if (path == "~") return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); return path; } private async System.Threading.Tasks.Task LoadPlanningChildrenAsync(string parentTaskId, CancellationToken ct) { try { await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var children = await ctx.Tasks .AsNoTracking() .Include(t => t.Worktree) .Where(t => t.ParentTaskId == parentTaskId) .ToListAsync(ct); ct.ThrowIfCancellationRequested(); foreach (var child in children) { var existing = Subtasks.FirstOrDefault(s => s.Id == child.Id); if (existing != null) { existing.Status = child.Status; existing.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active; } } if (MergeTargetBranches.Count == 0) { var childWithWorktree = children.FirstOrDefault(c => c.Worktree != null); if (childWithWorktree != null) { var targets = await _worker.GetMergeTargetsAsync(childWithWorktree.Id); if (targets != null) { MergeTargetBranches.Clear(); foreach (var b in targets.LocalBranches) MergeTargetBranches.Add(b); SelectedMergeTarget = targets.DefaultBranch; } } } } catch (OperationCanceledException) { } catch { /* best-effort */ } } private async System.Threading.Tasks.Task RefreshPlanningChildAsync(string childTaskId) { if (Task is null) return; try { await using var ctx = await _dbFactory.CreateDbContextAsync(); var child = await ctx.Tasks .AsNoTracking() .Include(t => t.Worktree) .FirstOrDefaultAsync(t => t.Id == childTaskId && t.ParentTaskId == Task.Id); if (child == null) return; var existing = Subtasks.FirstOrDefault(s => s.Id == child.Id); if (existing != null) { existing.Status = child.Status; existing.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active; } } catch { /* best-effort */ } } // Live-update a single improvement child's outcome row from a task event. No-op if the // updated task isn't one of this parent's children. private async System.Threading.Tasks.Task RefreshChildOutcomeAsync(string childTaskId) { var row = ChildOutcomes.FirstOrDefault(c => c.Id == childTaskId); if (row is null) return; try { await using var ctx = await _dbFactory.CreateDbContextAsync(); var child = await ctx.Tasks .AsNoTracking() .Include(t => t.Worktree) .FirstOrDefaultAsync(t => t.Id == childTaskId); if (child is null) return; row.Status = child.Status; row.RoadblockCount = child.RoadblockCount; row.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active; ReviewCombinedDiffCommand.NotifyCanExecuteChanged(); } catch { /* best-effort */ } } [RelayCommand(CanExecute = nameof(CanReviewDiff))] private async System.Threading.Tasks.Task ReviewCombinedDiffAsync() { if (Task is null || ShowPlanningDiffModal is null) return; var vm = new ViewModels.Planning.PlanningDiffViewModel(_worker, Task.Id, SelectedMergeTarget ?? "main"); await vm.InitializeAsync(); await ShowPlanningDiffModal(vm); } private bool CanReviewDiff() => (Task?.IsPlanningParent == true && Subtasks.Any()) || HasChildOutcomes; private async System.Threading.Tasks.Task RefreshWorktreeAsync(string taskId) { try { await using var ctx = await _dbFactory.CreateDbContextAsync(); var entity = await ctx.Tasks .AsNoTracking() .Include(t => t.Worktree) .FirstOrDefaultAsync(t => t.Id == taskId); if (entity == null || Task?.Id != taskId) return; WorktreePath = entity.Worktree?.Path; WorktreeBaseCommit = entity.Worktree?.BaseCommit; WorktreeStateLabel = entity.Worktree?.State.ToString(); BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null; AgentState = StatusToStateKey(entity.Status); if (Task is { } row && entity.Worktree?.DiffStat is { } stat) row.DiffStat = stat; var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat); DiffAdditions = add; DiffDeletions = del; } catch { /* best-effort refresh */ } } private async System.Threading.Tasks.Task RefreshMergePreviewAsync() { if (Task is null || WorktreePath is null) { MergePreviewText = ""; MergeIsClean = false; MergeIsConflict = false; return; } // Only probe Active worktrees; terminal states show their label instead. if (WorktreeStateLabel is { } label && label != "Active") { MergePreviewText = label; MergeIsClean = false; MergeIsConflict = false; return; } var capturedTaskId = Task.Id; var capturedTarget = SelectedMergeTarget; var dto = await _worker.PreviewMergeAsync(capturedTaskId, capturedTarget ?? ""); // Discard a probe that resolved after the user switched task or target. if (Task?.Id != capturedTaskId || SelectedMergeTarget != capturedTarget) return; var (text, clean, conflict) = MergePreviewPresenter.Describe(dto); MergePreviewText = text; MergeIsClean = clean; MergeIsConflict = conflict; } [RelayCommand(CanExecute = nameof(CanOpenDiff))] private async System.Threading.Tasks.Task OpenDiffAsync() { if (WorktreePath == null || ShowDiffModal == null) return; var diffVm = new DiffModalViewModel(_services.GetRequiredService()) { WorktreePath = WorktreePath, BaseRef = WorktreeBaseCommit, TaskId = Task?.Id, TaskTitle = Task?.Title ?? "", ShowMergeModal = ShowMergeModal, ResolveMergeVm = () => _services.GetRequiredService(), }; await diffVm.LoadAsync(); await ShowDiffModal(diffVm); } private bool CanOpenDiff() => WorktreePath != null; [RelayCommand(CanExecute = nameof(CanOpenWorktree))] private void OpenWorktree() { if (WorktreePath == null) return; try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = WorktreePath, UseShellExecute = true, }); } catch { /* explorer open is best-effort */ } } private bool CanOpenWorktree() => WorktreePath != null; partial void OnSelectedMergeTargetChanged(string? value) { _ = RefreshMergePreviewAsync(); } partial void OnWorktreePathChanged(string? value) { OpenDiffCommand.NotifyCanExecuteChanged(); OpenWorktreeCommand.NotifyCanExecuteChanged(); NotifySessionSections(); } partial void OnTaskChanged(TaskRowViewModel? value) => NotifySessionSections(); [RelayCommand] private void CloseDetails() => CloseDetail?.Invoke(); [RelayCommand] private async System.Threading.Tasks.Task ToggleStarAsync() { if (Task is null) return; Task.IsStarred = !Task.IsStarred; await using var ctx = _dbFactory.CreateDbContext(); var repo = new TaskRepository(ctx); var entity = await repo.GetByIdAsync(Task.Id); if (entity is null) return; entity.IsStarred = Task.IsStarred; await repo.UpdateAsync(entity); } [RelayCommand] private async System.Threading.Tasks.Task ToggleDoneAsync() { if (Task is null) return; Task.Done = !Task.Done; await using var ctx = _dbFactory.CreateDbContext(); var repo = new TaskRepository(ctx); var entity = await repo.GetByIdAsync(Task.Id); if (entity is null) return; entity.Status = Task.Done ? ClaudeDo.Data.Models.TaskStatus.Done : ClaudeDo.Data.Models.TaskStatus.Idle; Task.Status = entity.Status; AgentState = StatusToStateKey(entity.Status); await repo.UpdateAsync(entity); } [RelayCommand] private async System.Threading.Tasks.Task ToggleSubtaskDoneAsync(SubtaskRowViewModel? row) { if (row is null) return; row.Done = !row.Done; NotifyStepsChanged(); await using var ctx = _dbFactory.CreateDbContext(); var repo = new SubtaskRepository(ctx); var subs = await repo.GetByTaskIdAsync(Task?.Id ?? ""); var entity = subs.FirstOrDefault(s => s.Id == row.Id); if (entity is null) return; entity.Completed = row.Done; await repo.UpdateAsync(entity); } [RelayCommand] private async System.Threading.Tasks.Task DeleteTaskAsync() { if (Task == null) return; var row = Task; if (ConfirmAsync != null) { var ok = await ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone."); if (!ok) return; } try { await using var ctx = _dbFactory.CreateDbContext(); var repo = new TaskRepository(ctx); await repo.DeleteAsync(row.Id); } catch (DbUpdateException ex) when ( ex.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) || ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true) { if (ShowErrorAsync != null) await ShowErrorAsync("This task has child tasks. Discard the planning session or delete child tasks first."); return; } if (DeleteFromList != null) await DeleteFromList(row); CloseDetail?.Invoke(); } [RelayCommand] private async System.Threading.Tasks.Task CommitSubtaskEditAsync(SubtaskRowViewModel? row) { if (row is null || !row.IsEditing) return; row.IsEditing = false; var title = row.Title?.Trim() ?? ""; await using var ctx = _dbFactory.CreateDbContext(); var repo = new SubtaskRepository(ctx); // Emptying the text removes the step. if (string.IsNullOrEmpty(title)) { await repo.DeleteAsync(row.Id); Subtasks.Remove(row); return; } var subs = await repo.GetByTaskIdAsync(Task?.Id ?? ""); var entity = subs.FirstOrDefault(s => s.Id == row.Id); if (entity is null) return; if (entity.Title != title) { entity.Title = title; await repo.UpdateAsync(entity); } row.Title = title; OnPropertyChanged(nameof(ComposedPreview)); } [RelayCommand] private async System.Threading.Tasks.Task AddSubtaskAsync() { if (Task is null) return; var title = NewSubtaskTitle?.Trim(); if (string.IsNullOrEmpty(title)) return; var entity = new ClaudeDo.Data.Models.SubtaskEntity { Id = Guid.NewGuid().ToString(), TaskId = Task.Id, Title = title, Completed = false, OrderNum = Subtasks.Count, CreatedAt = DateTime.UtcNow, }; await using var ctx = _dbFactory.CreateDbContext(); await new SubtaskRepository(ctx).AddAsync(entity); Subtasks.Add(new SubtaskRowViewModel { Id = entity.Id, Title = entity.Title, Done = entity.Completed }); NewSubtaskTitle = ""; } [RelayCommand] private async System.Threading.Tasks.Task StopAsync() { if (Task == null || !IsRunning) return; if (!_worker.IsConnected) return; try { await _worker.CancelTaskAsync(Task.Id); } catch { /* offline */ } } [RelayCommand(CanExecute = nameof(CanEnqueue))] private async System.Threading.Tasks.Task EnqueueAsync() { if (Task == null) return; try { await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued); AgentState = "queued"; } catch { /* offline */ } } private bool CanEnqueue() => Task != null && _worker.IsConnected && IsIdle && (!Task.IsChild || Task.ParentFinalized); [RelayCommand(CanExecute = nameof(CanDequeue))] private async System.Threading.Tasks.Task DequeueAsync() { if (Task == null) return; try { await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Idle); AgentState = "idle"; } catch { /* offline */ } } private bool CanDequeue() => Task != null && _worker.IsConnected && IsQueued; [RelayCommand(CanExecute = nameof(CanContinue))] private async System.Threading.Tasks.Task ContinueAsync() { if (Task == null) return; await _worker.ContinueTaskAsync(Task.Id, "Continue working on this task."); } private bool CanContinue() => Task != null && _worker.IsConnected && ShowContinue && !string.IsNullOrEmpty(LatestRunSessionId); [RelayCommand(CanExecute = nameof(CanResetAndRetry))] private async System.Threading.Tasks.Task ResetAndRetryAsync() { if (Task == null) return; if (ConfirmAsync == null) return; var branchName = $"claudedo/{Task.Id.Replace("-", "")}"; var ok = await ConfirmAsync( $"Reset and retry?\nThis discards branch {branchName} (and uncommitted changes), then queues the task to run from the beginning."); if (!ok) return; if (WorktreePath != null) await _worker.ResetTaskAsync(Task.Id); try { await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued); AgentState = "queued"; } catch { /* offline */ } } private bool CanResetAndRetry() => Task != null && _worker.IsConnected && ShowResetAndRetry; [RelayCommand] private void ResetTaskModel() => TaskModelSelection = null; [RelayCommand] private void ResetTaskTurns() => TaskMaxTurns = null; [RelayCommand] private void ResetTaskAgent() => TaskSelectedAgent = TaskAgentOptions.Count > 0 ? TaskAgentOptions[0] : null; // ── Review actions ────────────────────────────────────────────────────────── [ObservableProperty] private string _reviewFeedback = ""; [RelayCommand] private async System.Threading.Tasks.Task ApproveReviewAsync() { if (Task is null || !_worker.IsConnected) return; try { var hasChildren = Subtasks.Count > 0 || ChildOutcomes.Count > 0; var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? ""); if (!hasChildren && result?.Status == "conflict") { if (RequestConflictResolution is not null) { await RequestConflictResolution(Task.Id, SelectedMergeTarget ?? ""); } else { var (text, _, _) = MergePreviewPresenter.Describe( new MergePreviewDto("conflict", result.ConflictFiles, 0)); MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true; } } // hasChildren: conflicts arrive via PlanningMergeConflictEvent → conflict dialog } catch { /* stale review action; broadcast reconciles */ } } [RelayCommand] private async System.Threading.Tasks.Task RejectReviewAsync() { if (Task is null || !_worker.IsConnected) return; var feedback = ReviewFeedback; if (string.IsNullOrWhiteSpace(feedback)) return; try { await _worker.RejectReviewToQueueAsync(Task.Id, feedback); } catch { /* stale review action; broadcast reconciles */ return; } ReviewFeedback = ""; } [RelayCommand] private async System.Threading.Tasks.Task ResetReviewAsync() { if (Task is null || !_worker.IsConnected || ConfirmAsync is null) return; var branchName = $"claudedo/{Task.Id.Replace("-", "")}"; var ok = await ConfirmAsync( $"Reset working tree?\nThis discards branch {branchName} (and all changes) and returns the task to Idle."); if (!ok) return; try { await _worker.ResetTaskAsync(Task.Id); } catch { /* stale review action; broadcast reconciles */ } } [RelayCommand] private async System.Threading.Tasks.Task CancelReviewAsync() { if (Task is null || !_worker.IsConnected) return; try { await _worker.CancelReviewAsync(Task.Id); } catch { /* stale review action; broadcast reconciles */ } } // ── Diff meter parser ─────────────────────────────────────────────────────── internal static (int Additions, int Deletions) ParseDiffStat(string? stat) { if (string.IsNullOrEmpty(stat)) return (0, 0); int add = 0, del = 0; var m1 = System.Text.RegularExpressions.Regex.Match(stat, @"(\d+)\s+insertion"); var m2 = System.Text.RegularExpressions.Regex.Match(stat, @"(\d+)\s+deletion"); if (m1.Success) int.TryParse(m1.Groups[1].Value, out add); if (m2.Success) int.TryParse(m2.Groups[1].Value, out del); return (add, del); } } public sealed partial class SubtaskRowViewModel : ViewModelBase { public required string Id { get; init; } [ObservableProperty] private string _title = ""; [ObservableProperty] private bool _done; [ObservableProperty] private bool _isEditing; [ObservableProperty] private ClaudeDo.Data.Models.TaskStatus _status; [ObservableProperty] private ClaudeDo.Data.Models.WorktreeState _worktreeState = ClaudeDo.Data.Models.WorktreeState.Active; } // A suggested child's outcome on an improvement parent's review card. Observable so the // row reflects the child's live status (Idle → Running → Done/Failed) as it executes. public sealed partial class ChildOutcomeRowViewModel : ViewModelBase { public required string Id { get; init; } public required string Title { get; init; } [ObservableProperty] [NotifyPropertyChangedFor(nameof(StatusLabel))] private ClaudeDo.Data.Models.TaskStatus _status; [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasRoadblock))] [NotifyPropertyChangedFor(nameof(RoadblockText))] private int _roadblockCount; [ObservableProperty] private ClaudeDo.Data.Models.WorktreeState _worktreeState = ClaudeDo.Data.Models.WorktreeState.Active; public string StatusLabel => Status switch { ClaudeDo.Data.Models.TaskStatus.Done => Loc.T("vm.taskStatus.done"), ClaudeDo.Data.Models.TaskStatus.Failed => Loc.T("vm.taskStatus.failed"), ClaudeDo.Data.Models.TaskStatus.Cancelled => Loc.T("vm.taskStatus.cancelled"), ClaudeDo.Data.Models.TaskStatus.Running => Loc.T("vm.taskStatus.running"), ClaudeDo.Data.Models.TaskStatus.Queued => Loc.T("vm.taskStatus.queued"), ClaudeDo.Data.Models.TaskStatus.WaitingForReview => Loc.T("vm.taskStatus.waitingForReview"), ClaudeDo.Data.Models.TaskStatus.WaitingForChildren => Loc.T("vm.taskStatus.waitingForChildren"), _ => Loc.T("vm.taskStatus.idle"), }; public bool HasRoadblock => RoadblockCount > 0; public string RoadblockText => RoadblockCount == 1 ? "1 roadblock" : $"{RoadblockCount} roadblocks"; }