diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/AgentSettingsSectionViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/AgentSettingsSectionViewModel.cs new file mode 100644 index 0000000..f4327e9 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Islands/AgentSettingsSectionViewModel.cs @@ -0,0 +1,196 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.Helpers; +using ClaudeDo.Ui.Localization; +using ClaudeDo.Ui.Services; + +namespace ClaudeDo.Ui.ViewModels.Islands; + +public sealed partial class AgentSettingsSectionViewModel : ViewModelBase, IDisposable +{ + private readonly IWorkerClient _worker; + private readonly EventHandler _langChangedHandler; + + internal string? TaskId { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsAgentSectionEnabled))] + private bool _isRunning; + + public bool IsAgentSectionEnabled => !IsRunning; + + [ObservableProperty] private string? _taskModelSelection; + [ObservableProperty] private string _taskSystemPrompt = ""; + [ObservableProperty] private AgentInfo? _taskSelectedAgent; + [ObservableProperty] private decimal? _taskMaxTurns; + [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; + + private bool _suppressAgentSave; + private CancellationTokenSource? _agentSaveCts; + + public int EffectiveMaxTurns => + TaskMaxTurns is decimal t ? (int)t : (_listMaxTurns ?? _globalMaxTurns); + + public ObservableCollection TaskModelOptions { get; } = new(ModelRegistry.Aliases); + public ObservableCollection TaskAgentOptions { get; } = new(); + + public AgentSettingsSectionViewModel(IWorkerClient worker) + { + _worker = worker; + _langChangedHandler = (_, _) => + { + RecomputeModelBadge(); + RecomputeTurnsBadge(); + RecomputeAgentBadge(); + }; + Loc.LanguageChanged += _langChangedHandler; + } + + public void Dispose() => Loc.LanguageChanged -= _langChangedHandler; + + partial void OnTaskModelSelectionChanged(string? value) { RecomputeModelBadge(); QueueAgentSave(); } + + partial void OnTaskMaxTurnsChanged(decimal? value) + { + RecomputeTurnsBadge(); + OnPropertyChanged(nameof(EffectiveMaxTurns)); + QueueAgentSave(); + } + + partial void OnTaskSystemPromptChanged(string value) => QueueAgentSave(); + partial void OnTaskSelectedAgentChanged(AgentInfo? value) { RecomputeAgentBadge(); 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"); + + private void QueueAgentSave() + { + if (_suppressAgentSave || TaskId is null) return; + _agentSaveCts?.Cancel(); + _agentSaveCts = new CancellationTokenSource(); + _ = SaveAgentSettingsAsync(_agentSaveCts.Token); + } + + private async System.Threading.Tasks.Task SaveAgentSettingsAsync(CancellationToken ct) + { + try + { + await System.Threading.Tasks.Task.Delay(300, ct); + if (TaskId 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 UpdateTaskAgentSettingsDto(TaskId, model, sp, ap, turns)); + } + catch (OperationCanceledException) { } + catch { } + } + + internal async System.Threading.Tasks.Task LoadAsync( + 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)); + } + finally + { + _suppressAgentSave = false; + } + } + + internal void Clear() + { + _suppressAgentSave = true; + try + { + TaskModelSelection = null; + TaskMaxTurns = null; + TaskSystemPrompt = ""; + TaskSelectedAgent = null; + } + finally + { + _suppressAgentSave = false; + } + EffectiveSystemPromptHint = ""; + TaskId = null; + } + + [RelayCommand] private void ResetTaskModel() => TaskModelSelection = null; + [RelayCommand] private void ResetTaskTurns() => TaskMaxTurns = null; + [RelayCommand] private void ResetTaskAgent() => + TaskSelectedAgent = TaskAgentOptions.Count > 0 ? TaskAgentOptions[0] : null; +} diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index a314716..a1ff6e3 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -53,6 +53,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable private readonly IServiceProvider _services; private readonly INotesApi _notesApi; + // ── Section view models ─────────────────────────────────────────────────── + public AgentSettingsSectionViewModel AgentSettings { get; } + public MergeSectionViewModel Merge { get; } + public PrepPanelViewModel Prep { get; } + // Captured handler delegates for disposal private readonly EventHandler _langChangedHandler; private readonly System.ComponentModel.PropertyChangedEventHandler _workerPropertyChangedHandler; @@ -63,8 +68,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable [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)); @@ -77,7 +81,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable [NotifyCanExecuteChangedFor(nameof(EnqueueCommand))] [NotifyCanExecuteChangedFor(nameof(DequeueCommand))] [NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))] - [NotifyCanExecuteChangedFor(nameof(ReviewCombinedDiffCommand))] [NotifyPropertyChangedFor(nameof(TaskIdBadge))] private TaskRowViewModel? _task; @@ -112,9 +115,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable [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] @@ -135,14 +135,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable 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))] @@ -156,39 +152,25 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable [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)); + Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count); NotifyAttention(); - // 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 TurnsText => $"{Turns}/{AgentSettings.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; @@ -197,8 +179,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable !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; @@ -207,8 +187,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable !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) @@ -235,7 +213,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable 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] @@ -251,12 +228,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable 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"; + 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] @@ -276,89 +251,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable OnPropertyChanged(nameof(IsCancelled)); OnPropertyChanged(nameof(ShowContinue)); OnPropertyChanged(nameof(ShowResetAndRetry)); - OnPropertyChanged(nameof(IsAgentSectionEnabled)); OnPropertyChanged(nameof(ShowRoadblock)); OnPropertyChanged(nameof(RoadblockMessage)); OnPropertyChanged(nameof(ShowSessionOutcome)); OnPropertyChanged(nameof(ShowRoadblockCard)); + AgentSettings.IsRunning = IsRunning; 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? _worktreeHeadCommit; [ObservableProperty] private string? _worktreeStateLabel; - // Repo working dir of the selected task's list — used to diff a merged task's - // commit range after its worktree directory is gone. private string? _listWorkingDir; [ObservableProperty] private string? _branchLine; [ObservableProperty] private int _turns; @@ -368,14 +274,13 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable [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 + public string ElapsedFormatted => ""; 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 @@ -387,16 +292,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable 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; - // Children that need the user's attention before the parent can be approved: - // failed, cancelled, still awaiting their own review, or that reported roadblocks. - // The parent deliberately stays in WaitingForChildren until these are resolved; - // this surfaces a flag so the roadblock is visible on the parent. public int ChildrenNeedingAttention => ChildOutcomes.Count(c => c.Status == ClaudeDo.Data.Models.TaskStatus.Failed || c.Status == ClaudeDo.Data.Models.TaskStatus.Cancelled @@ -416,59 +314,37 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable [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; + private bool _suppressDescSave; + private CancellationTokenSource? _descSaveCts; + // 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; } + // ── Review ────────────────────────────────────────────────────────────── + [ObservableProperty] private string _reviewFeedback = ""; + + // Kept for backwards-compat surface — delegates to Merge.RequestConflictResolution + public Func? RequestConflictResolution + { + get => Merge.RequestConflictResolution; + set => Merge.RequestConflictResolution = value; + } private static string StatusToStateKey(ClaudeDo.Data.Models.TaskStatus status) => status switch { @@ -507,8 +383,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable 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 @@ -522,30 +396,36 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable catch { } } - public DetailsIslandViewModel(IDbContextFactory dbFactory, IWorkerClient worker, IServiceProvider services, INotesApi notesApi) + public DetailsIslandViewModel( + IDbContextFactory dbFactory, + IWorkerClient worker, + IServiceProvider services, + INotesApi notesApi) { _dbFactory = dbFactory; _worker = worker; _services = services; _notesApi = notesApi; + + AgentSettings = new AgentSettingsSectionViewModel(worker); + Merge = new MergeSectionViewModel(worker, services); + Prep = new PrepPanelViewModel(worker); + Notes = new NotesEditorViewModel(_notesApi); Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged(); - _langChangedHandler = (_, _) => + Subtasks.CollectionChanged += (_, _) => Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count); + + AgentSettings.PropertyChanged += (_, e) => { - OnPropertyChanged(nameof(AgentStatusLabel)); - RecomputeModelBadge(); - RecomputeTurnsBadge(); - RecomputeAgentBadge(); + if (e.PropertyName == nameof(AgentSettingsSectionViewModel.EffectiveMaxTurns)) + OnPropertyChanged(nameof(TurnsText)); }; + + _langChangedHandler = (_, _) => OnPropertyChanged(nameof(AgentStatusLabel)); 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(IWorkerClient.IsConnected)) @@ -558,7 +438,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable }; _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"; @@ -576,7 +455,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable 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); @@ -599,18 +477,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable }; _worker.TaskUpdatedEvent += _workerTaskUpdatedHandler; - Subtasks.CollectionChanged += (_, _) => - { - ReviewCombinedDiffCommand.NotifyCanExecuteChanged(); - }; - ChildOutcomes.CollectionChanged += (_, _) => { - ReviewCombinedDiffCommand.NotifyCanExecuteChanged(); + Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count); NotifySessionSections(); }; - - PrepLog.CollectionChanged += (_, _) => OnPropertyChanged(nameof(ShowPrepEmptyState)); } public void Dispose() @@ -622,25 +493,21 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable _worker.WorktreeUpdatedEvent -= _workerWorktreeUpdatedHandler; _worker.TaskUpdatedEvent -= _workerTaskUpdatedHandler; _worker.TaskMessageEvent -= OnTaskMessage; - _worker.PrepStartedEvent -= OnPrepStarted; - _worker.PrepLineEvent -= OnPrepLine; - _worker.PrepFinishedEvent -= OnPrepFinished; + AgentSettings.Dispose(); + Prep.Dispose(); } 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); + AppendStdoutLine(body); return; } - // Non-stdout tagged lines: flush any buffered text then classify by prefix. FlushClaudeBuffer(); var kind = line.StartsWith("[sys]", StringComparison.OrdinalIgnoreCase) ? LogKind.Sys @@ -652,52 +519,21 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable Log.Add(new LogLineViewModel { Kind = kind, Text = line }); } - private void AppendStdoutLine(ObservableCollection target, string line) + private void AppendStdoutLine(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. + _claudeBuf.Append(formatted); while (true) { - var text = buf.ToString(); + var text = _claudeBuf.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)..]); + Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece }); + _claudeBuf.Clear(); + _claudeBuf.Append(text[(nl + 1)..]); } } @@ -710,9 +546,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable 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; @@ -738,76 +571,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable 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); @@ -821,21 +584,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable 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); - } + _ = Prep.LoadLastPrepLogIfEmptyAsync(); } public void Bind(TaskRowViewModel? row) @@ -853,11 +602,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable Subtasks.Clear(); ChildOutcomes.Clear(); OnPropertyChanged(nameof(HasChildOutcomes)); - MergeTargetBranches.Clear(); - SelectedMergeTarget = null; SessionOutcome = null; Roadblocks = null; _claudeBuf.Clear(); + Merge.Clear(); if (row == null) { @@ -874,19 +622,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable DiffDeletions = 0; AgentState = "idle"; LatestRunSessionId = null; - _suppressAgentSave = true; - try - { - TaskModelSelection = null; - TaskMaxTurns = null; - TaskSystemPrompt = ""; - TaskSelectedAgent = null; - } - finally - { - _suppressAgentSave = false; - } - EffectiveSystemPromptHint = ""; + AgentSettings.Clear(); return; } @@ -900,7 +636,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable 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) @@ -919,12 +654,18 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable WorktreeBaseCommit = entity.Worktree?.BaseCommit; WorktreeHeadCommit = entity.Worktree?.HeadCommit; WorktreeStateLabel = entity.Worktree?.State.ToString(); - BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null; + BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null; var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat); DiffAdditions = add; DiffDeletions = del; AgentState = StatusToStateKey(entity.Status); - await LoadAgentSettingsAsync(entity, ct); + + Merge.SyncTaskContext(row.Id, row.Title, row.IsPlanningParent); + Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit, + WorktreeStateLabel, _listWorkingDir); + + AgentSettings.TaskId = row.Id; + await AgentSettings.LoadAsync(entity, ct); ct.ThrowIfCancellationRequested(); var runRepo = new TaskRunRepository(ctx); @@ -933,10 +674,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable 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); @@ -946,31 +685,25 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable if (entity.PlanningPhase != ClaudeDo.Data.Models.PlanningPhase.None) await LoadPlanningChildrenAsync(row.Id, ct); - // Surface every parent's children — planning or improvement — in the - // Session tab with their live status + roadblock count. This is what - // makes the Session tab appear for planning parents and lets a child's - // roadblock register on the parent. await LoadChildOutcomesAsync(row.Id, ct); if (entity.Worktree != null && entity.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.None - && MergeTargetBranches.Count == 0) + && Merge.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 + Merge.MergeTargetBranches.Clear(); + foreach (var b in targets.LocalBranches) Merge.MergeTargetBranches.Add(b); + Merge.SelectedMergeTarget = targets.DefaultBranch; } } - await RefreshMergePreviewAsync(); + await Merge.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 @@ -997,7 +730,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable }); OnPropertyChanged(nameof(HasChildOutcomes)); - if (MergeTargetBranches.Count == 0) + if (Merge.MergeTargetBranches.Count == 0) { var childWithWorktree = children.FirstOrDefault(c => c.Worktree != null); if (childWithWorktree != null) @@ -1005,14 +738,13 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable var targets = await _worker.GetMergeTargetsAsync(childWithWorktree.Id); if (targets != null) { - MergeTargetBranches.Clear(); + Merge.MergeTargetBranches.Clear(); foreach (var b in targets.LocalBranches) - MergeTargetBranches.Add(b); - SelectedMergeTarget = targets.DefaultBranch; + Merge.MergeTargetBranches.Add(b); + Merge.SelectedMergeTarget = targets.DefaultBranch; } } } - } catch (OperationCanceledException) { } catch { /* best-effort */ } @@ -1047,9 +779,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable { 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); @@ -1093,7 +822,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable } } - if (MergeTargetBranches.Count == 0) + if (Merge.MergeTargetBranches.Count == 0) { var childWithWorktree = children.FirstOrDefault(c => c.Worktree != null); if (childWithWorktree != null) @@ -1101,14 +830,13 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable var targets = await _worker.GetMergeTargetsAsync(childWithWorktree.Id); if (targets != null) { - MergeTargetBranches.Clear(); + Merge.MergeTargetBranches.Clear(); foreach (var b in targets.LocalBranches) - MergeTargetBranches.Add(b); - SelectedMergeTarget = targets.DefaultBranch; + Merge.MergeTargetBranches.Add(b); + Merge.SelectedMergeTarget = targets.DefaultBranch; } } } - } catch (OperationCanceledException) { } catch { /* best-effort */ } @@ -1132,13 +860,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable 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); @@ -1154,23 +879,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable row.Status = child.Status; row.RoadblockCount = child.RoadblockCount; row.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active; - ReviewCombinedDiffCommand.NotifyCanExecuteChanged(); + Merge.ReviewCombinedDiffCommand.NotifyCanExecuteChanged(); NotifyAttention(); } 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 @@ -1195,116 +909,29 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat); DiffAdditions = add; DiffDeletions = del; + + Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit, + WorktreeStateLabel, _listWorkingDir); } 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 (ShowDiffModal == null) return; - var git = _services.GetRequiredService(); - - // Active worktree on disk → diff the worktree live (and allow merging from it). - var hasLiveWorktree = - WorktreePath != null - && WorktreeStateLabel == "Active" - && System.IO.Directory.Exists(WorktreePath); - - DiffModalViewModel diffVm; - if (hasLiveWorktree) - { - diffVm = new DiffModalViewModel(git) - { - WorktreePath = WorktreePath!, - BaseRef = WorktreeBaseCommit, - TaskId = Task?.Id, - TaskTitle = Task?.Title ?? "", - ShowMergeModal = ShowMergeModal, - ResolveMergeVm = () => _services.GetRequiredService(), - }; - } - else if (CanDiffMergedRange) - { - // Worktree is gone (merged/discarded) but the commits survive on the - // target branch — diff the captured base..head range in the repo. No - // merge action: the work is already integrated. - diffVm = new DiffModalViewModel(git) - { - WorktreePath = _listWorkingDir!, - BaseRef = WorktreeBaseCommit, - HeadCommit = WorktreeHeadCommit, - FromCommitRange = true, - TaskId = Task?.Id, - TaskTitle = Task?.Title ?? "", - }; - } - else return; - - await diffVm.LoadAsync(); - await ShowDiffModal(diffVm); - } - - private bool CanDiffMergedRange => - WorktreeBaseCommit != null && WorktreeHeadCommit != null && _listWorkingDir != null; - - private bool CanOpenDiff() => WorktreePath != null || CanDiffMergedRange; - - [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(); + Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit, + WorktreeStateLabel, _listWorkingDir); NotifySessionSections(); } partial void OnWorktreeHeadCommitChanged(string? value) => - OpenDiffCommand.NotifyCanExecuteChanged(); + Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit, + WorktreeStateLabel, _listWorkingDir); - partial void OnTaskChanged(TaskRowViewModel? value) => NotifySessionSections(); + partial void OnTaskChanged(TaskRowViewModel? value) + { + Merge.SyncTaskContext(Task?.Id, Task?.Title, Task?.IsPlanningParent == true); + NotifySessionSections(); + } [RelayCommand] private void CloseDetails() => CloseDetail?.Invoke(); @@ -1370,7 +997,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable var repo = new TaskRepository(ctx); await repo.DeleteAsync(row.Id); } - catch (DbUpdateException ex) when ( + catch (Microsoft.EntityFrameworkCore.DbUpdateException ex) when ( ex.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) || ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true) { @@ -1393,7 +1020,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable 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); @@ -1512,13 +1138,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable 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() { @@ -1526,26 +1145,25 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable try { var hasChildren = Subtasks.Count > 0 || ChildOutcomes.Count > 0; - var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? ""); + var result = await _worker.ApproveReviewAsync(Task.Id, Merge.SelectedMergeTarget ?? ""); if (!hasChildren && result?.Status == "conflict") { - if (RequestConflictResolution is not null) + if (Merge.RequestConflictResolution is not null) { - await RequestConflictResolution(Task.Id, SelectedMergeTarget ?? ""); + await Merge.RequestConflictResolution(Task.Id, Merge.SelectedMergeTarget ?? ""); } else { var (text, _, _) = MergePreviewPresenter.Describe( new MergePreviewDto("conflict", result.ConflictFiles, 0)); - MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true; + Merge.MergePreviewText = text; + Merge.MergeIsClean = false; + Merge.MergeIsConflict = true; } } - // hasChildren: conflicts arrive via PlanningMergeConflictEvent → conflict dialog } catch (Exception ex) { - // A real failure (e.g. a child still needs attention, so the unit can't - // be approved yet) must not vanish — tell the user why nothing happened. if (ShowErrorAsync != null) await ShowErrorAsync(ex.Message); } @@ -1582,7 +1200,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable 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); diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs new file mode 100644 index 0000000..a3f6819 --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs @@ -0,0 +1,200 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using System.Collections.ObjectModel; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels.Modals; +using Microsoft.Extensions.DependencyInjection; + +namespace ClaudeDo.Ui.ViewModels.Islands; + +public sealed partial class MergeSectionViewModel : ViewModelBase +{ + private readonly IWorkerClient _worker; + private readonly IServiceProvider _services; + + // Context mirrored from parent, updated via Sync* methods + internal string? TaskId { get; private set; } + internal string? TaskTitle { get; private set; } + private string? _worktreePath; + private string? _worktreeBaseCommit; + private string? _worktreeHeadCommit; + private string? _worktreeStateLabel; + private string? _listWorkingDir; + private bool _isPlanningParent; + private int _subtaskCount; + private bool _hasChildOutcomes; + + [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); + + public bool ShowMergeSection => + _worktreePath != null || _isPlanningParent || _hasChildOutcomes; + + public Func? ShowDiffModal { get; set; } + public Func? ShowMergeModal { get; set; } + public Func? ShowPlanningDiffModal { get; set; } + public Func? RequestConflictResolution { get; set; } + + public MergeSectionViewModel(IWorkerClient worker, IServiceProvider services) + { + _worker = worker; + _services = services; + } + + partial void OnSelectedMergeTargetChanged(string? value) => _ = RefreshMergePreviewAsync(); + + internal void SyncWorktree( + string? worktreePath, + string? worktreeBase, + string? worktreeHead, + string? worktreeState, + string? listWorkDir) + { + _worktreePath = worktreePath; + _worktreeBaseCommit = worktreeBase; + _worktreeHeadCommit = worktreeHead; + _worktreeStateLabel = worktreeState; + _listWorkingDir = listWorkDir; + OnPropertyChanged(nameof(ShowMergeSection)); + OpenDiffCommand.NotifyCanExecuteChanged(); + OpenWorktreeCommand.NotifyCanExecuteChanged(); + } + + internal void SyncTaskContext(string? taskId, string? taskTitle, bool isPlanningParent) + { + TaskId = taskId; + TaskTitle = taskTitle; + _isPlanningParent = isPlanningParent; + OnPropertyChanged(nameof(ShowMergeSection)); + } + + internal void SyncChildOutcomes(bool hasChildOutcomes, int subtaskCount) + { + _hasChildOutcomes = hasChildOutcomes; + _subtaskCount = subtaskCount; + OnPropertyChanged(nameof(ShowMergeSection)); + ReviewCombinedDiffCommand.NotifyCanExecuteChanged(); + } + + internal async System.Threading.Tasks.Task RefreshMergePreviewAsync() + { + if (TaskId is null || _worktreePath is null) + { + MergePreviewText = ""; MergeIsClean = false; MergeIsConflict = false; + return; + } + if (_worktreeStateLabel is { } label && label != "Active") + { + MergePreviewText = label; MergeIsClean = false; MergeIsConflict = false; + return; + } + var capturedTaskId = TaskId; + var capturedTarget = SelectedMergeTarget; + var dto = await _worker.PreviewMergeAsync(capturedTaskId, capturedTarget ?? ""); + if (TaskId != capturedTaskId || SelectedMergeTarget != capturedTarget) return; + var (text, clean, conflict) = MergePreviewPresenter.Describe(dto); + MergePreviewText = text; MergeIsClean = clean; MergeIsConflict = conflict; + } + + internal void Clear() + { + MergeTargetBranches.Clear(); + SelectedMergeTarget = null; + MergePreviewText = ""; + MergeIsClean = false; + MergeIsConflict = false; + SyncWorktree(null, null, null, null, null); + SyncTaskContext(null, null, false); + SyncChildOutcomes(false, 0); + } + + [RelayCommand(CanExecute = nameof(CanReviewDiff))] + private async System.Threading.Tasks.Task ReviewCombinedDiffAsync() + { + if (TaskId is null || ShowPlanningDiffModal is null) return; + var vm = new ViewModels.Planning.PlanningDiffViewModel(_worker, TaskId, SelectedMergeTarget ?? "main"); + await vm.InitializeAsync(); + await ShowPlanningDiffModal(vm); + } + + private bool CanReviewDiff() => (_isPlanningParent && _subtaskCount > 0) || _hasChildOutcomes; + + [RelayCommand(CanExecute = nameof(CanOpenDiff))] + private async System.Threading.Tasks.Task OpenDiffAsync() + { + if (ShowDiffModal is null) return; + var git = _services.GetRequiredService(); + + var hasLiveWorktree = + _worktreePath != null + && _worktreeStateLabel == "Active" + && System.IO.Directory.Exists(_worktreePath); + + DiffModalViewModel diffVm; + if (hasLiveWorktree) + { + diffVm = new DiffModalViewModel(git) + { + WorktreePath = _worktreePath!, + BaseRef = _worktreeBaseCommit, + TaskId = TaskId, + TaskTitle = TaskTitle ?? "", + ShowMergeModal = ShowMergeModal, + ResolveMergeVm = () => _services.GetRequiredService(), + }; + } + else if (CanDiffMergedRange) + { + diffVm = new DiffModalViewModel(git) + { + WorktreePath = _listWorkingDir!, + BaseRef = _worktreeBaseCommit, + HeadCommit = _worktreeHeadCommit, + FromCommitRange = true, + TaskId = TaskId, + TaskTitle = TaskTitle ?? "", + }; + } + else return; + + await diffVm.LoadAsync(); + await ShowDiffModal(diffVm); + } + + private bool CanDiffMergedRange => + _worktreeBaseCommit != null && _worktreeHeadCommit != null && _listWorkingDir != null; + + private bool CanOpenDiff() => _worktreePath != null || CanDiffMergedRange; + + [RelayCommand(CanExecute = nameof(CanOpenWorktree))] + private void OpenWorktree() + { + if (_worktreePath is null) return; + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = _worktreePath, + UseShellExecute = true, + }); + } + catch { } + } + + private bool CanOpenWorktree() => _worktreePath != null; +} diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/PrepPanelViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/PrepPanelViewModel.cs new file mode 100644 index 0000000..11b37ce --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Islands/PrepPanelViewModel.cs @@ -0,0 +1,102 @@ +using System.Collections.ObjectModel; +using System.Text; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using ClaudeDo.Ui.Helpers; +using ClaudeDo.Ui.Services; + +namespace ClaudeDo.Ui.ViewModels.Islands; + +public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable +{ + private readonly IWorkerClient _worker; + private readonly StreamLineFormatter _formatter = new(); + private readonly StringBuilder _prepClaudeBuf = new(); + + private readonly Action _onPrepStartedHandler; + private readonly Action _onPrepLineHandler; + private readonly Action _onPrepFinishedHandler; + + [ObservableProperty] private bool _isPrepRunning; + + public ObservableCollection PrepLog { get; } = new(); + + public bool ShowPrepEmptyState => !IsPrepRunning && PrepLog.Count == 0; + + partial void OnIsPrepRunningChanged(bool value) => OnPropertyChanged(nameof(ShowPrepEmptyState)); + + public PrepPanelViewModel(IWorkerClient worker) + { + _worker = worker; + _onPrepStartedHandler = OnPrepStarted; + _onPrepLineHandler = OnPrepLine; + _onPrepFinishedHandler = OnPrepFinished; + + _worker.PrepStartedEvent += _onPrepStartedHandler; + _worker.PrepLineEvent += _onPrepLineHandler; + _worker.PrepFinishedEvent += _onPrepFinishedHandler; + + PrepLog.CollectionChanged += (_, _) => OnPropertyChanged(nameof(ShowPrepEmptyState)); + } + + public void Dispose() + { + _worker.PrepStartedEvent -= _onPrepStartedHandler; + _worker.PrepLineEvent -= _onPrepLineHandler; + _worker.PrepFinishedEvent -= _onPrepFinishedHandler; + } + + [RelayCommand] + private async System.Threading.Tasks.Task PlanDayAsync() + { + try { await _worker.RunDailyPrepNowAsync(); } + catch { } + } + + public async System.Threading.Tasks.Task LoadLastPrepLogIfEmptyAsync() + { + if (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(trimmed); + } + } + + private void OnPrepStarted() + { + PrepLog.Clear(); + IsPrepRunning = true; + } + + private void OnPrepLine(string line) => AppendStdoutLine(line); + + private void OnPrepFinished(bool success) => IsPrepRunning = false; + + private void AppendStdoutLine(string line) + { + var formatted = _formatter.FormatLine(line); + if (formatted is null) return; + AppendClaudeText(formatted); + } + + private void AppendClaudeText(string chunk) + { + _prepClaudeBuf.Append(chunk); + while (true) + { + var text = _prepClaudeBuf.ToString(); + var nl = text.IndexOf('\n'); + if (nl < 0) break; + var piece = text[..nl].TrimEnd('\r'); + if (!string.IsNullOrWhiteSpace(piece)) + PrepLog.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece }); + _prepClaudeBuf.Clear(); + _prepClaudeBuf.Append(text[(nl + 1)..]); + } + } +} diff --git a/src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml b/src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml index 986df67..1228750 100644 --- a/src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml @@ -138,8 +138,8 @@ -