feat(ui): add settings modal and wire to worker hub

This commit is contained in:
Mika Kuns
2026-04-21 15:55:53 +02:00
parent fca5d57fef
commit e6b37624a1
9 changed files with 644 additions and 5 deletions

View File

@@ -226,6 +226,47 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
await _hub.DisposeAsync();
}
public async Task<AppSettingsDto?> GetAppSettingsAsync()
{
try
{
return await _hub.InvokeAsync<AppSettingsDto>("GetAppSettings");
}
catch
{
return null;
}
}
public async Task UpdateAppSettingsAsync(AppSettingsDto dto)
{
await _hub.InvokeAsync("UpdateAppSettings", dto);
}
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync()
{
try
{
return await _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees");
}
catch
{
return null;
}
}
public async Task<WorktreeResetDto?> ResetAllWorktreesAsync()
{
try
{
return await _hub.InvokeAsync<WorktreeResetDto>("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);

View File

@@ -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();
}
}

View File

@@ -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<string> Models { get; } = new[] { "opus", "sonnet", "haiku" };
public IReadOnlyList<string> PermissionModes { get; } = new[]
{ "bypassPermissions", "acceptEdits", "plan", "default" };
public IReadOnlyList<string> 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 */ }
}
}

View File

@@ -15,7 +15,7 @@
<KeyBinding Gesture="Shift+OemQuestion" Command="{Binding FocusSearchCommand}"/>
<KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/>
</Window.KeyBindings>
<Grid RowDefinitions="36,*">
<Grid RowDefinitions="36,*,22">
<!-- Custom title bar -->
<Border Grid.Row="0"
Background="{DynamicResource DeepBrush}"
@@ -98,5 +98,38 @@
<islands:DetailsIslandView DataContext="{Binding Details}"/>
</Border>
</Grid>
<!-- Footer: connection status -->
<Border Grid.Row="2"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" Spacing="7"
VerticalAlignment="Center" Margin="14,0">
<Ellipse Width="7" Height="7" Fill="#4CAF50"
IsVisible="{Binding Worker.IsConnected}"/>
<Ellipse Width="7" Height="7" Fill="#FFA726"
IsVisible="{Binding Worker.IsReconnecting}"/>
<Ellipse Width="7" Height="7" Fill="#EF5350"
IsVisible="{Binding IsOffline}"/>
<TextBlock Text="{Binding ConnectionText, Converter={StaticResource UpperCase}}"
FontFamily="{DynamicResource MonoFont}"
FontSize="10"
LetterSpacing="1.4"
Foreground="{DynamicResource TextDimBrush}"
VerticalAlignment="Center"/>
<TextBlock Text="·"
FontFamily="{DynamicResource MonoFont}"
FontSize="10"
Foreground="{DynamicResource TextFaintBrush}"
VerticalAlignment="Center"/>
<TextBlock Text="WORKER"
FontFamily="{DynamicResource MonoFont}"
FontSize="10"
LetterSpacing="1.4"
Foreground="{DynamicResource TextFaintBrush}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,253 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
x:Class="ClaudeDo.Ui.Views.Modals.SettingsModalView"
x:DataType="vm:SettingsModalViewModel"
Title="Settings"
Width="580" Height="760"
SystemDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
</Window.KeyBindings>
<Window.Styles>
<Style Selector="TextBlock.section-label">
<Setter Property="FontFamily" Value="{DynamicResource MonoFont}"/>
<Setter Property="FontSize" Value="10"/>
<Setter Property="LetterSpacing" Value="1.4"/>
<Setter Property="Foreground" Value="{DynamicResource TextFaintBrush}"/>
<Setter Property="Margin" Value="4,0,0,6"/>
</Style>
<Style Selector="TextBlock.field-label">
<Setter Property="FontSize" Value="11"/>
<Setter Property="Foreground" Value="{DynamicResource TextDimBrush}"/>
<Setter Property="Margin" Value="0,0,0,4"/>
</Style>
<Style Selector="TextBlock.path-mono">
<Setter Property="FontFamily" Value="{DynamicResource MonoFont}"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="Foreground" Value="{DynamicResource TextDimBrush}"/>
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
</Style>
<Style Selector="Border.section">
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="6"/>
<Setter Property="Padding" Value="14"/>
<Setter Property="Background" Value="{DynamicResource DeepBrush}"/>
</Style>
<Style Selector="Button.danger">
<Setter Property="Background" Value="{DynamicResource BloodBrush}"/>
<Setter Property="Foreground" Value="White"/>
</Style>
<Style Selector="Button.primary">
<Setter Property="Background" Value="{DynamicResource AccentBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource DeepBrush}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
</Window.Styles>
<Border Background="{DynamicResource SurfaceBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1">
<Grid RowDefinitions="36,*,52">
<!-- Title bar -->
<Border Grid.Row="0"
x:Name="TitleBar"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1"
PointerPressed="TitleBar_PointerPressed">
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
<TextBlock Text="SETTINGS"
FontFamily="{DynamicResource MonoFont}"
FontSize="11"
LetterSpacing="1.4"
Foreground="{DynamicResource TextBrush}"
VerticalAlignment="Center"/>
<Button Grid.Column="1"
Classes="icon-btn"
Content="✕"
FontSize="12"
Command="{Binding CancelCommand}"
VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Scrollable body -->
<ScrollViewer Grid.Row="1" Padding="20,16">
<StackPanel Spacing="18">
<!-- CLAUDE DEFAULTS -->
<StackPanel Spacing="0">
<TextBlock Classes="section-label" Text="CLAUDE DEFAULTS"/>
<Border Classes="section">
<StackPanel Spacing="12">
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="Default instructions"/>
<TextBox AcceptsReturn="True"
TextWrapping="Wrap"
Height="110"
Watermark="Baseline instructions applied to every task (e.g. 'speak German', 'never touch .env')"
Text="{Binding DefaultClaudeInstructions, Mode=TwoWay}"/>
</StackPanel>
<Grid ColumnDefinitions="*,12,*,12,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Classes="field-label" Text="Model"/>
<ComboBox ItemsSource="{Binding Models}"
SelectedItem="{Binding DefaultModel, Mode=TwoWay}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Classes="field-label" Text="Max turns"/>
<NumericUpDown Value="{Binding DefaultMaxTurns, Mode=TwoWay}"
Minimum="1" Maximum="200" Increment="1" FormatString="0"/>
</StackPanel>
<StackPanel Grid.Column="4" Spacing="4">
<TextBlock Classes="field-label" Text="Permission"/>
<ComboBox ItemsSource="{Binding PermissionModes}"
SelectedItem="{Binding DefaultPermissionMode, Mode=TwoWay}"
HorizontalAlignment="Stretch"/>
</StackPanel>
</Grid>
</StackPanel>
</Border>
</StackPanel>
<!-- WORKTREES -->
<StackPanel Spacing="0">
<TextBlock Classes="section-label" Text="WORKTREES"/>
<Border Classes="section">
<StackPanel Spacing="12">
<Grid ColumnDefinitions="*,12,2*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Classes="field-label" Text="Strategy"/>
<ComboBox ItemsSource="{Binding WorktreeStrategies}"
SelectedItem="{Binding WorktreeStrategy, Mode=TwoWay}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Classes="field-label" Text="Central worktree root"/>
<TextBox Text="{Binding CentralWorktreeRoot, Mode=TwoWay}"
Watermark="e.g. C:\worktrees"/>
</StackPanel>
</Grid>
<StackPanel Orientation="Horizontal" Spacing="8">
<CheckBox IsChecked="{Binding WorktreeAutoCleanupEnabled, Mode=TwoWay}"
Content="Auto-cleanup finished worktrees after"
VerticalAlignment="Center"/>
<NumericUpDown Value="{Binding WorktreeAutoCleanupDays, Mode=TwoWay}"
Width="130" Minimum="1" Maximum="365" Increment="1" FormatString="0"
IsEnabled="{Binding WorktreeAutoCleanupEnabled}"/>
<TextBlock Text="days" VerticalAlignment="Center"
Foreground="{DynamicResource TextDimBrush}"/>
</StackPanel>
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,4,0,0"/>
<StackPanel Spacing="8">
<Button Content="Cleanup finished worktrees"
Command="{Binding CleanupWorktreesCommand}"
HorizontalAlignment="Left"/>
<!-- Force-remove: button vs. confirm bar -->
<StackPanel>
<Button Content="Force-remove all worktrees"
Classes="danger"
Command="{Binding RequestResetConfirmCommand}"
HorizontalAlignment="Left"
IsVisible="{Binding !ShowResetConfirm}"/>
<Border BorderBrush="{DynamicResource BloodBrush}" BorderThickness="1"
CornerRadius="6" Padding="12,10"
IsVisible="{Binding ShowResetConfirm}">
<StackPanel Spacing="8">
<TextBlock Text="Remove ALL worktrees? Uncommitted work will be lost."
Foreground="{DynamicResource TextBrush}" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Cancel" Command="{Binding CancelResetConfirmCommand}"/>
<Button Content="Remove All" Classes="danger"
Command="{Binding ConfirmResetAllCommand}"/>
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
<!-- ABOUT -->
<StackPanel Spacing="0">
<TextBlock Classes="section-label" Text="ABOUT"/>
<Border Classes="section">
<Grid RowDefinitions="Auto,Auto,Auto,Auto" ColumnDefinitions="90,*,Auto" RowSpacing="8">
<TextBlock Grid.Row="0" Grid.Column="0" Classes="field-label" Text="Version"
VerticalAlignment="Center"/>
<TextBlock Grid.Row="0" Grid.Column="1" Classes="path-mono"
Text="{Binding AppVersion}" VerticalAlignment="Center"/>
<TextBlock Grid.Row="1" Grid.Column="0" Classes="field-label" Text="Data"
VerticalAlignment="Center"/>
<TextBlock Grid.Row="1" Grid.Column="1" Classes="path-mono"
Text="{Binding DataFolderPath}" VerticalAlignment="Center"/>
<Button Grid.Row="1" Grid.Column="2" Content="Open"
Command="{Binding OpenPathCommand}"
CommandParameter="{Binding DataFolderPath}"/>
<TextBlock Grid.Row="2" Grid.Column="0" Classes="field-label" Text="Logs"
VerticalAlignment="Center"/>
<TextBlock Grid.Row="2" Grid.Column="1" Classes="path-mono"
Text="{Binding LogsFolderPath}" VerticalAlignment="Center"/>
<Button Grid.Row="2" Grid.Column="2" Content="Open"
Command="{Binding OpenPathCommand}"
CommandParameter="{Binding LogsFolderPath}"/>
<TextBlock Grid.Row="3" Grid.Column="0" Classes="field-label" Text="Config"
VerticalAlignment="Center"/>
<TextBlock Grid.Row="3" Grid.Column="1" Classes="path-mono"
Text="{Binding WorkerConfigPath}" VerticalAlignment="Center"/>
<Button Grid.Row="3" Grid.Column="2" Content="Open"
Command="{Binding OpenPathCommand}"
CommandParameter="{Binding WorkerConfigPath}"/>
</Grid>
</Border>
</StackPanel>
<!-- Inline status / error -->
<TextBlock Text="{Binding ValidationError}"
Foreground="{DynamicResource BloodBrush}"
FontSize="11"
IsVisible="{Binding ValidationError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding StatusMessage}"
Foreground="{DynamicResource TextDimBrush}"
FontSize="11"
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</ScrollViewer>
<!-- Footer -->
<Border Grid.Row="2"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" VerticalAlignment="Center"
Margin="16,0">
<Button Content="Cancel" Command="{Binding CancelCommand}" MinWidth="90"/>
<Button Content="Save" Classes="primary"
Command="{Binding SaveCommand}"
IsEnabled="{Binding !IsBusy}" MinWidth="90"/>
</StackPanel>
</Border>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,26 @@
using Avalonia.Controls;
using Avalonia.Input;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals;
public partial class SettingsModalView : Window
{
public SettingsModalView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is SettingsModalViewModel vm)
vm.CloseAction = Close;
}
private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
BeginMoveDrag(e);
}
}