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

@@ -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<IslandsShellViewModel>(),
};
// Kick off the SignalR retry loop — reconnects indefinitely if the worker
// is not up yet, or goes down and comes back.
_ = Services.GetRequiredService<WorkerClient>().StartAsync();
}
base.OnFrameworkInitializationCompleted();

View File

@@ -77,9 +77,13 @@ sealed class Program
// ViewModels
sc.AddTransient<WorktreeModalViewModel>();
sc.AddTransient<SettingsModalViewModel>();
// Islands shell VMs
sc.AddSingleton<ListsIslandViewModel>();
sc.AddSingleton<ListsIslandViewModel>(sp =>
new ListsIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp));
sc.AddSingleton<TasksIslandViewModel>(sp =>
new TasksIslandViewModel(sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>()));
sc.AddSingleton<DetailsIslandViewModel>(sp =>

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

View File

@@ -1,12 +1,28 @@
using System.Reflection;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Services;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Worker.Hub;
public record ActiveTaskDto(string Slot, string TaskId, DateTime StartedAt);
public record AppSettingsDto(
string DefaultClaudeInstructions,
string DefaultModel,
int DefaultMaxTurns,
string DefaultPermissionMode,
string WorktreeStrategy,
string? CentralWorktreeRoot,
bool WorktreeAutoCleanupEnabled,
int WorktreeAutoCleanupDays);
public record WorktreeCleanupDto(int Removed);
public record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
{
private static readonly string Version =
@@ -15,12 +31,21 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly QueueService _queue;
private readonly AgentFileService _agentService;
private readonly HubBroadcaster _broadcaster;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorktreeMaintenanceService _wtMaintenance;
public WorkerHub(QueueService queue, AgentFileService agentService, HubBroadcaster broadcaster)
public WorkerHub(
QueueService queue,
AgentFileService agentService,
HubBroadcaster broadcaster,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeMaintenanceService wtMaintenance)
{
_queue = queue;
_agentService = agentService;
_broadcaster = broadcaster;
_dbFactory = dbFactory;
_wtMaintenance = wtMaintenance;
}
public string Ping() => $"pong v{Version}";
@@ -71,4 +96,49 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
public async Task<List<AgentInfo>> GetAgents() => await _agentService.ScanAsync();
public async Task RefreshAgents() => await _agentService.ScanAsync();
public async Task<AppSettingsDto> GetAppSettings()
{
using var ctx = _dbFactory.CreateDbContext();
var row = await new AppSettingsRepository(ctx).GetAsync();
return new AppSettingsDto(
row.DefaultClaudeInstructions,
row.DefaultModel,
row.DefaultMaxTurns,
row.DefaultPermissionMode,
row.WorktreeStrategy,
row.CentralWorktreeRoot,
row.WorktreeAutoCleanupEnabled,
row.WorktreeAutoCleanupDays);
}
public async Task UpdateAppSettings(AppSettingsDto dto)
{
using var ctx = _dbFactory.CreateDbContext();
var repo = new AppSettingsRepository(ctx);
await repo.UpdateAsync(new AppSettingsEntity
{
Id = AppSettingsEntity.SingletonId,
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,
});
}
public async Task<WorktreeCleanupDto> CleanupFinishedWorktrees()
{
var result = await _wtMaintenance.CleanupFinishedAsync();
return new WorktreeCleanupDto(result.Removed);
}
public async Task<WorktreeResetDto> ResetAllWorktrees()
{
var result = await _wtMaintenance.ResetAllAsync();
return new WorktreeResetDto(result.Removed, result.TasksAffected, result.Blocked, result.RunningTasks);
}
}