diff --git a/src/ClaudeDo.App/App.axaml.cs b/src/ClaudeDo.App/App.axaml.cs index f38700c..cd99389 100644 --- a/src/ClaudeDo.App/App.axaml.cs +++ b/src/ClaudeDo.App/App.axaml.cs @@ -1,6 +1,7 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; +using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.Views; using Microsoft.Extensions.DependencyInjection; @@ -24,6 +25,10 @@ public partial class App : Application { DataContext = Services.GetRequiredService(), }; + + // Kick off the SignalR retry loop — reconnects indefinitely if the worker + // is not up yet, or goes down and comes back. + _ = Services.GetRequiredService().StartAsync(); } base.OnFrameworkInitializationCompleted(); diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index 173652f..11aee7d 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -77,9 +77,13 @@ sealed class Program // ViewModels sc.AddTransient(); + sc.AddTransient(); // Islands shell VMs - sc.AddSingleton(); + sc.AddSingleton(sp => + new ListsIslandViewModel( + sp.GetRequiredService>(), + sp)); sc.AddSingleton(sp => new TasksIslandViewModel(sp.GetRequiredService>())); sc.AddSingleton(sp => diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 32e94d7..1f4d355 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -226,6 +226,47 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable await _hub.DisposeAsync(); } + public async Task GetAppSettingsAsync() + { + try + { + return await _hub.InvokeAsync("GetAppSettings"); + } + catch + { + return null; + } + } + + public async Task UpdateAppSettingsAsync(AppSettingsDto dto) + { + await _hub.InvokeAsync("UpdateAppSettings", dto); + } + + public async Task CleanupFinishedWorktreesAsync() + { + try + { + return await _hub.InvokeAsync("CleanupFinishedWorktrees"); + } + catch + { + return null; + } + } + + public async Task ResetAllWorktreesAsync() + { + try + { + return await _hub.InvokeAsync("ResetAllWorktrees"); + } + catch + { + return null; + } + } + // DTOs for deserializing hub responses private sealed class ActiveTaskDto { @@ -234,3 +275,16 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable public DateTime StartedAt { get; set; } } } + +public sealed record AppSettingsDto( + string DefaultClaudeInstructions, + string DefaultModel, + int DefaultMaxTurns, + string DefaultPermissionMode, + string WorktreeStrategy, + string? CentralWorktreeRoot, + bool WorktreeAutoCleanupEnabled, + int WorktreeAutoCleanupDays); + +public sealed record WorktreeCleanupDto(int Removed); +public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks); diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index 25b7e8d..37adff9 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -1,5 +1,6 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels.Islands; namespace ClaudeDo.Ui.ViewModels; @@ -9,6 +10,14 @@ public sealed partial class IslandsShellViewModel : ViewModelBase public ListsIslandViewModel Lists { get; } public TasksIslandViewModel Tasks { get; } public DetailsIslandViewModel Details { get; } + public WorkerClient Worker { get; } + + public string ConnectionText => + Worker.IsConnected ? "Online" + : Worker.IsReconnecting ? "Connecting…" + : "Offline"; + + public bool IsOffline => !Worker.IsConnected && !Worker.IsReconnecting; [ObservableProperty] private double _windowWidth = 1280; @@ -37,9 +46,10 @@ public sealed partial class IslandsShellViewModel : ViewModelBase public IslandsShellViewModel( ListsIslandViewModel lists, TasksIslandViewModel tasks, - DetailsIslandViewModel details) + DetailsIslandViewModel details, + WorkerClient worker) { - Lists = lists; Tasks = tasks; Details = details; + Lists = lists; Tasks = tasks; Details = details; Worker = worker; Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList); Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask); Details.CloseDetail = () => Tasks.SelectedTask = null; @@ -48,6 +58,14 @@ public sealed partial class IslandsShellViewModel : ViewModelBase Tasks.LoadForList(Lists.SelectedList); return System.Threading.Tasks.Task.CompletedTask; }; + Worker.PropertyChanged += (_, e) => + { + if (e.PropertyName is nameof(WorkerClient.IsConnected) or nameof(WorkerClient.IsReconnecting)) + { + OnPropertyChanged(nameof(ConnectionText)); + OnPropertyChanged(nameof(IsOffline)); + } + }; _ = Lists.LoadAsync(); } } diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs new file mode 100644 index 0000000..7ba8b3a --- /dev/null +++ b/src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs @@ -0,0 +1,176 @@ +using System.Diagnostics; +using System.IO; +using System.Reflection; +using ClaudeDo.Data; +using ClaudeDo.Ui.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClaudeDo.Ui.ViewModels.Modals; + +public sealed partial class SettingsModalViewModel : ViewModelBase +{ + private readonly WorkerClient _worker; + + [ObservableProperty] private string _defaultClaudeInstructions = ""; + [ObservableProperty] private string _defaultModel = "sonnet"; + [ObservableProperty] private int _defaultMaxTurns = 30; + [ObservableProperty] private string _defaultPermissionMode = "bypassPermissions"; + [ObservableProperty] private string _worktreeStrategy = "sibling"; + [ObservableProperty] private string? _centralWorktreeRoot; + [ObservableProperty] private bool _worktreeAutoCleanupEnabled; + [ObservableProperty] private int _worktreeAutoCleanupDays = 7; + + [ObservableProperty] private string _statusMessage = ""; + [ObservableProperty] private bool _isBusy; + [ObservableProperty] private bool _showResetConfirm; + [ObservableProperty] private string _validationError = ""; + + public IReadOnlyList Models { get; } = new[] { "opus", "sonnet", "haiku" }; + public IReadOnlyList PermissionModes { get; } = new[] + { "bypassPermissions", "acceptEdits", "plan", "default" }; + public IReadOnlyList WorktreeStrategies { get; } = new[] { "sibling", "central" }; + + public string AppVersion { get; } = + Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0"; + + public string DataFolderPath { get; } = Paths.AppDataRoot(); + public string LogsFolderPath { get; } = Path.Combine(Paths.AppDataRoot(), "logs"); + public string WorkerConfigPath { get; } = Path.Combine(Paths.AppDataRoot(), "worker.config.json"); + + public Action? CloseAction { get; set; } + + public SettingsModalViewModel(WorkerClient worker) + { + _worker = worker; + } + + public async Task LoadAsync() + { + IsBusy = true; + try + { + var dto = await _worker.GetAppSettingsAsync(); + if (dto is not null) + { + DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? ""; + DefaultModel = dto.DefaultModel ?? "sonnet"; + DefaultMaxTurns = dto.DefaultMaxTurns; + DefaultPermissionMode = dto.DefaultPermissionMode ?? "bypassPermissions"; + WorktreeStrategy = dto.WorktreeStrategy ?? "sibling"; + CentralWorktreeRoot = dto.CentralWorktreeRoot; + WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled; + WorktreeAutoCleanupDays = dto.WorktreeAutoCleanupDays; + } + else + { + StatusMessage = "Worker offline — settings read-only."; + } + } + finally { IsBusy = false; } + } + + private bool Validate() + { + if (DefaultMaxTurns < 1 || DefaultMaxTurns > 200) + { ValidationError = "Max turns must be between 1 and 200."; return false; } + + if (WorktreeAutoCleanupEnabled && + (WorktreeAutoCleanupDays < 1 || WorktreeAutoCleanupDays > 365)) + { ValidationError = "Cleanup days must be between 1 and 365."; return false; } + + if (WorktreeStrategy == "central") + { + if (string.IsNullOrWhiteSpace(CentralWorktreeRoot)) + { ValidationError = "Central worktree root is required for Central strategy."; return false; } + if (!Directory.Exists(CentralWorktreeRoot)) + { ValidationError = $"Directory not found: {CentralWorktreeRoot}"; return false; } + } + + ValidationError = ""; + return true; + } + + [RelayCommand] + private async Task Save() + { + if (!Validate()) return; + + IsBusy = true; + try + { + var dto = new AppSettingsDto( + DefaultClaudeInstructions ?? "", + DefaultModel ?? "sonnet", + DefaultMaxTurns, + DefaultPermissionMode ?? "bypassPermissions", + WorktreeStrategy ?? "sibling", + string.IsNullOrWhiteSpace(CentralWorktreeRoot) ? null : CentralWorktreeRoot, + WorktreeAutoCleanupEnabled, + WorktreeAutoCleanupDays); + await _worker.UpdateAppSettingsAsync(dto); + CloseAction?.Invoke(); + } + catch (Exception ex) + { + StatusMessage = $"Save failed: {ex.Message}"; + } + finally { IsBusy = false; } + } + + [RelayCommand] + private void Cancel() => CloseAction?.Invoke(); + + [RelayCommand] + private async Task CleanupWorktrees() + { + IsBusy = true; + StatusMessage = ""; + try + { + var result = await _worker.CleanupFinishedWorktreesAsync(); + StatusMessage = result is null + ? "Worker offline." + : $"Removed {result.Removed} worktree(s)."; + } + finally { IsBusy = false; } + } + + [RelayCommand] + private void RequestResetConfirm() => ShowResetConfirm = true; + + [RelayCommand] + private void CancelResetConfirm() => ShowResetConfirm = false; + + [RelayCommand] + private async Task ConfirmResetAll() + { + ShowResetConfirm = false; + IsBusy = true; + StatusMessage = ""; + try + { + var result = await _worker.ResetAllWorktreesAsync(); + if (result is null) + StatusMessage = "Worker offline."; + else if (result.Blocked) + StatusMessage = $"Cannot force-remove: {result.RunningTasks} task(s) still running. Cancel them first."; + else + StatusMessage = $"Removed {result.Removed} worktree(s) from {result.TasksAffected} task(s)."; + } + finally { IsBusy = false; } + } + + [RelayCommand] + private void OpenPath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) return; + try + { + var target = File.Exists(path) ? path : (Directory.Exists(path) ? path : null); + if (target is null) return; + Process.Start(new ProcessStartInfo("explorer.exe", $"\"{target}\"") { UseShellExecute = true }); + } + catch { /* ignore */ } + } +} diff --git a/src/ClaudeDo.Ui/Views/MainWindow.axaml b/src/ClaudeDo.Ui/Views/MainWindow.axaml index 470e225..0f76871 100644 --- a/src/ClaudeDo.Ui/Views/MainWindow.axaml +++ b/src/ClaudeDo.Ui/Views/MainWindow.axaml @@ -15,7 +15,7 @@ - + + + + + + + + + + + + + diff --git a/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml new file mode 100644 index 0000000..09fa7b9 --- /dev/null +++ b/src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml @@ -0,0 +1,253 @@ + + + + + + + + + + + + + + + + + + + + + + +