feat(ui): session skills registry tab + per-level selectors

This commit is contained in:
Mika Kuns
2026-07-23 16:47:14 +02:00
committed by mika kuns
parent b4c58087d2
commit 7c3c061428
12 changed files with 562 additions and 10 deletions
@@ -1,4 +1,7 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Text.Json;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data.Models;
@@ -57,6 +60,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
public ObservableCollection<string> ModelOptions { get; } = new(ModelRegistry.Aliases);
public ObservableCollection<AgentInfo> Agents { get; } = new();
public ObservableCollection<SelectableSkillViewModel> SessionSkills { get; } = new();
public AgentConfigEditorViewModel(IWorkerClient worker, AgentConfigScope scope)
{
@@ -67,9 +71,31 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
// short-lived modal recreated with the current language on each open.
if (scope == AgentConfigScope.Task)
Loc.LanguageChanged += _langChangedHandler;
SessionSkills.CollectionChanged += OnSessionSkillsCollectionChanged;
}
public void Dispose() => Loc.LanguageChanged -= _langChangedHandler;
private void OnSessionSkillsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems is not null)
foreach (SelectableSkillViewModel item in e.NewItems)
item.PropertyChanged += OnSkillItemPropertyChanged;
if (e.OldItems is not null)
foreach (SelectableSkillViewModel item in e.OldItems)
item.PropertyChanged -= OnSkillItemPropertyChanged;
}
private void OnSkillItemPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(SelectableSkillViewModel.IsSelected)) QueueSave();
}
public void Dispose()
{
Loc.LanguageChanged -= _langChangedHandler;
SessionSkills.CollectionChanged -= OnSessionSkillsCollectionChanged;
foreach (var s in SessionSkills) s.PropertyChanged -= OnSkillItemPropertyChanged;
}
partial void OnModelChanged(string? value) { RecomputeModelBadge(); QueueSave(); }
@@ -154,11 +180,18 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
var sp = string.IsNullOrWhiteSpace(SystemPrompt) ? null : SystemPrompt;
var ap = SelectedAgent is null || string.IsNullOrWhiteSpace(SelectedAgent.Path) ? null : SelectedAgent.Path;
var turns = MaxTurns is decimal d ? (int?)d : null;
var skills = SelectedSessionSkillNames();
if (_scope == AgentConfigScope.Task)
await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns));
await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills));
else
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns));
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills));
}
private List<string>? SelectedSessionSkillNames()
{
var names = SessionSkills.Where(s => s.IsSelected).Select(s => s.Name).ToList();
return names.Count == 0 ? null : names;
}
public async System.Threading.Tasks.Task LoadForListAsync(string listId, CancellationToken ct = default)
@@ -172,6 +205,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
var cfg = await _worker.GetListConfigAsync(listId);
ApplyConfig(cfg?.Model, cfg?.MaxTurns, cfg?.SystemPrompt, cfg?.AgentPath);
await ReloadSessionSkillsAsync(cfg?.SessionSkills);
_listModel = null; _listMaxTurns = null; _listAgentName = null;
EffectiveSystemPromptHint = "";
@@ -189,6 +223,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
TargetId = entity.Id;
await ReloadAgentsAsync("(inherited)");
ApplyConfig(entity.Model, entity.MaxTurns, entity.SystemPrompt, entity.AgentPath);
await ReloadSessionSkillsAsync(ParseSessionSkills(entity.SessionSkills));
var listCfg = await _worker.GetListConfigAsync(entity.ListId);
await LoadGlobalDefaultsAsync();
@@ -214,12 +249,30 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
MaxTurns = null;
SystemPrompt = "";
SelectedAgent = null;
foreach (var s in SessionSkills) s.IsSelected = false;
}
finally { _suppressSave = false; }
EffectiveSystemPromptHint = "";
TargetId = null;
}
private static List<string>? ParseSessionSkills(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
try { return JsonSerializer.Deserialize<List<string>>(json); }
catch (JsonException) { return null; }
}
private async System.Threading.Tasks.Task ReloadSessionSkillsAsync(IReadOnlyCollection<string>? selected)
{
var installed = await _worker.GetSessionSkillsAsync();
var selectedSet = selected is null ? new HashSet<string>() : new HashSet<string>(selected);
foreach (var s in SessionSkills) s.PropertyChanged -= OnSkillItemPropertyChanged;
SessionSkills.Clear();
foreach (var s in installed)
SessionSkills.Add(new SelectableSkillViewModel(s.Name, s.Description, selectedSet.Contains(s.Name)));
}
private async System.Threading.Tasks.Task ReloadAgentsAsync(string placeholderName)
{
Agents.Clear();
@@ -255,5 +308,6 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
MaxTurns = null;
SystemPrompt = "";
SelectedAgent = Agents.Count > 0 ? Agents[0] : null;
foreach (var s in SessionSkills) s.IsSelected = false;
}
}
@@ -0,0 +1,23 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels.Agent;
/// <summary>
/// One installed session skill shown as a checkbox row. Reused by the global (General tab),
/// list, and task selectors — selection is additive-union across all three levels, so this
/// is deliberately just a name + checked flag with no inheritance/override state.
/// </summary>
public sealed partial class SelectableSkillViewModel : ViewModelBase
{
public string Name { get; }
public string Description { get; }
[ObservableProperty] private bool _isSelected;
public SelectableSkillViewModel(string name, string description = "", bool isSelected = false)
{
Name = name;
Description = description;
_isSelected = isSelected;
}
}
@@ -1,5 +1,8 @@
using System.Collections.ObjectModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Agent;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
@@ -22,6 +25,8 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
public IReadOnlyList<string> Models { get; } = ModelRegistry.Aliases;
public IReadOnlyList<string> PermissionModes { get; } = PermissionModeRegistry.Modes;
public ObservableCollection<SelectableSkillViewModel> SessionSkills { get; } = new();
public GeneralSettingsTabViewModel() { }
public GeneralSettingsTabViewModel(ILocalizer localizer, Action<string> persist)
@@ -51,4 +56,20 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
return "Max parallel executions must be between 1 and 20.";
return null;
}
/// <summary>Loads the installed-skills checkbox list, reflecting the given selection.</summary>
public async Task LoadSessionSkillsAsync(IWorkerClient worker, IReadOnlyCollection<string>? selected)
{
var installed = await worker.GetSessionSkillsAsync();
var selectedSet = selected is null ? new HashSet<string>() : new HashSet<string>(selected);
SessionSkills.Clear();
foreach (var s in installed)
SessionSkills.Add(new SelectableSkillViewModel(s.Name, s.Description, selectedSet.Contains(s.Name)));
}
public List<string>? SelectedSessionSkillNames()
{
var names = SessionSkills.Where(s => s.IsSelected).Select(s => s.Name).ToList();
return names.Count == 0 ? null : names;
}
}
@@ -0,0 +1,78 @@
using System.Collections.ObjectModel;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
public sealed partial class SessionSkillsSettingsTabViewModel : ViewModelBase
{
private readonly IWorkerClient _worker;
[ObservableProperty] private string _installUrl = "";
[ObservableProperty] private string _statusMessage = "";
[ObservableProperty] private bool _isBusy;
public ObservableCollection<SessionSkillDto> Skills { get; } = new();
public SessionSkillsSettingsTabViewModel(IWorkerClient worker) => _worker = worker;
public async Task LoadAsync()
{
IsBusy = true;
try
{
var skills = await _worker.GetSessionSkillsAsync();
Skills.Clear();
foreach (var s in skills) Skills.Add(s);
}
finally { IsBusy = false; }
}
[RelayCommand]
private async Task InstallAsync()
{
if (string.IsNullOrWhiteSpace(InstallUrl)) return;
IsBusy = true; StatusMessage = "";
try
{
var installed = await _worker.InstallSessionSkillAsync(InstallUrl.Trim());
StatusMessage = Loc.T("vm.sessionSkillsTab.installed", string.Join(", ", installed));
InstallUrl = "";
await LoadAsync();
}
catch (Exception ex) { StatusMessage = Loc.T("vm.sessionSkillsTab.installFailed", ex.Message); }
finally { IsBusy = false; }
}
[RelayCommand]
private async Task UpdateAsync(string? sourceUrl)
{
if (string.IsNullOrWhiteSpace(sourceUrl)) return;
IsBusy = true; StatusMessage = "";
try
{
await _worker.UpdateSessionSkillAsync(sourceUrl);
StatusMessage = Loc.T("vm.sessionSkillsTab.updated");
await LoadAsync();
}
catch (Exception ex) { StatusMessage = Loc.T("vm.sessionSkillsTab.updateFailed", ex.Message); }
finally { IsBusy = false; }
}
[RelayCommand]
private async Task RemoveAsync(string? sourceUrl)
{
if (string.IsNullOrWhiteSpace(sourceUrl)) return;
IsBusy = true; StatusMessage = "";
try
{
await _worker.RemoveSessionSkillAsync(sourceUrl);
StatusMessage = Loc.T("vm.sessionSkillsTab.removed");
await LoadAsync();
}
catch (Exception ex) { StatusMessage = Loc.T("vm.sessionSkillsTab.removeFailed", ex.Message); }
finally { IsBusy = false; }
}
}
@@ -18,6 +18,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
public FilesSettingsTabViewModel Files { get; }
public PrimeClaudeTabViewModel Prime { get; }
public OnlineInboxSettingsViewModel OnlineInbox { get; }
public SessionSkillsSettingsTabViewModel SessionSkills { get; }
[ObservableProperty] private string _validationError = "";
[ObservableProperty] private bool _isBusy;
@@ -39,6 +40,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
Files = new FilesSettingsTabViewModel(worker);
Prime = prime;
OnlineInbox = new OnlineInboxSettingsViewModel(worker, onlineLoginService);
SessionSkills = new SessionSkillsSettingsTabViewModel(worker);
}
public async Task LoadAsync()
@@ -69,6 +71,8 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
await Prime.LoadAsync();
await OnlineInbox.LoadAsync();
await SessionSkills.LoadAsync();
await General.LoadSessionSkillsAsync(_worker, dto?.SessionSkills);
}
finally { IsBusy = false; }
}
@@ -97,7 +101,8 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
General.ReportExcludedPaths
.Split('\n').Select(l => l.Trim().TrimEnd('\r')).Where(l => l.Length > 0).ToList()),
General.StandupWeekday,
Prime.DailyPrepMaxTasks);
Prime.DailyPrepMaxTasks,
General.SelectedSessionSkillNames());
await _worker.UpdateAppSettingsAsync(dto);
await Prime.SaveAsync();
await OnlineInbox.SaveAsync();