feat(ui): session skills registry tab + per-level selectors
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
"tabWorktrees": "Worktrees",
|
||||
"tabFiles": "Dateien",
|
||||
"tabPrime": "Prime Claude",
|
||||
"tabSkills": "Skills",
|
||||
"general": {
|
||||
"defaultInstructions": "Standard-Anweisungen",
|
||||
"defaultInstructionsPlaceholder": "Basis-Anweisungen, die auf jede Aufgabe angewendet werden",
|
||||
@@ -25,7 +26,9 @@
|
||||
"weekdayWednesday": "Mittwoch",
|
||||
"weekdayThursday": "Donnerstag",
|
||||
"weekdayFriday": "Freitag",
|
||||
"weekdaySaturday": "Samstag"
|
||||
"weekdaySaturday": "Samstag",
|
||||
"sessionSkills": "Session-Skills",
|
||||
"sessionSkillsHint": "Gilt für jede Aufgabe. Kombiniert sich mit Listen- und Aufgaben-Auswahl."
|
||||
},
|
||||
"worktrees": {
|
||||
"strategy": "Strategie",
|
||||
@@ -95,7 +98,17 @@
|
||||
"systemPrompt": "System-Prompt (angehängt)",
|
||||
"promptPrepended": "Wird automatisch vorangestellt:",
|
||||
"agentFile": "Agent-Datei",
|
||||
"browse": "Durchsuchen..."
|
||||
"browse": "Durchsuchen...",
|
||||
"sessionSkills": "Session-Skills",
|
||||
"sessionSkillsHint": "Kombiniert sich additiv mit globaler (und Listen-)Auswahl."
|
||||
},
|
||||
"skills": {
|
||||
"installSection": "INSTALLIEREN",
|
||||
"installUrlPlaceholder": "Git-Repo-URL (owner/repo oder vollständige URL)",
|
||||
"installButton": "Installieren",
|
||||
"installedSection": "INSTALLIERT",
|
||||
"updateButton": "Aktualisieren",
|
||||
"removeButton": "Entfernen"
|
||||
}
|
||||
},
|
||||
"tasks": {
|
||||
@@ -494,7 +507,7 @@
|
||||
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Verbesserungen", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt" },
|
||||
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
|
||||
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
|
||||
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "runInteractiveFailed": "Interaktiv ausführen fehlgeschlagen: {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "pickUpInTerminalFailed": "Im Terminal fortsetzen fehlgeschlagen: {0}" },
|
||||
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "runInteractiveFailed": "Interaktiv ausführen fehlgeschlagen: {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "pickUpInTerminalFailed": "Im Terminal fortsetzen fehlgeschlagen: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}" },
|
||||
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
|
||||
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien)." },
|
||||
"merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
|
||||
@@ -503,6 +516,7 @@
|
||||
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
|
||||
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" },
|
||||
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "openFailed": "Öffnen fehlgeschlagen: {0}" },
|
||||
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
|
||||
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt." },
|
||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." },
|
||||
"listSettings": { "untitled": "Unbenannt" },
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"tabWorktrees": "Worktrees",
|
||||
"tabFiles": "Files",
|
||||
"tabPrime": "Prime Claude",
|
||||
"tabSkills": "Skills",
|
||||
"general": {
|
||||
"defaultInstructions": "Default instructions",
|
||||
"defaultInstructionsPlaceholder": "Baseline instructions applied to every task",
|
||||
@@ -25,7 +26,9 @@
|
||||
"weekdayWednesday": "Wednesday",
|
||||
"weekdayThursday": "Thursday",
|
||||
"weekdayFriday": "Friday",
|
||||
"weekdaySaturday": "Saturday"
|
||||
"weekdaySaturday": "Saturday",
|
||||
"sessionSkills": "Session skills",
|
||||
"sessionSkillsHint": "Applied to every task. Combines with list- and task-level selections."
|
||||
},
|
||||
"worktrees": {
|
||||
"strategy": "Strategy",
|
||||
@@ -95,7 +98,17 @@
|
||||
"systemPrompt": "System prompt (appended)",
|
||||
"promptPrepended": "Prepended automatically:",
|
||||
"agentFile": "Agent file",
|
||||
"browse": "Browse..."
|
||||
"browse": "Browse...",
|
||||
"sessionSkills": "Session skills",
|
||||
"sessionSkillsHint": "Combines additively with global (and list) selections."
|
||||
},
|
||||
"skills": {
|
||||
"installSection": "INSTALL",
|
||||
"installUrlPlaceholder": "Git repo URL (owner/repo or full URL)",
|
||||
"installButton": "Install",
|
||||
"installedSection": "INSTALLED",
|
||||
"updateButton": "Update",
|
||||
"removeButton": "Remove"
|
||||
}
|
||||
},
|
||||
"tasks": {
|
||||
@@ -494,7 +507,7 @@
|
||||
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Improvements", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked" },
|
||||
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
|
||||
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
|
||||
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "runInteractiveFailed": "Run interactively failed: {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "pickUpInTerminalFailed": "Pick up in terminal failed: {0}" },
|
||||
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "runInteractiveFailed": "Run interactively failed: {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "pickUpInTerminalFailed": "Pick up in terminal failed: {0}", "approveFailed": "Approve & merge failed: {0}" },
|
||||
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
|
||||
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files)." },
|
||||
"merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
|
||||
@@ -503,6 +516,7 @@
|
||||
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
|
||||
"weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" },
|
||||
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "openFailed": "Open failed: {0}" },
|
||||
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" },
|
||||
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s)." },
|
||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." },
|
||||
"listSettings": { "untitled": "Untitled" },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -81,5 +81,20 @@
|
||||
IsVisible="{Binding SelectedAgent.Path, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Session skills (additive union with list/global; no inheritance badge) -->
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr settings.agentEditor.sessionSkills}"/>
|
||||
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="{loc:Tr settings.agentEditor.sessionSkillsHint}"/>
|
||||
<ItemsControl ItemsSource="{Binding SessionSkills}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectableSkillViewModel">
|
||||
<CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay}" Content="{Binding Name}"
|
||||
ToolTip.Tip="{Binding Description}"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
|
||||
xmlns:settings="using:ClaudeDo.Ui.ViewModels.Modals.Settings"
|
||||
xmlns:agent="using:ClaudeDo.Ui.ViewModels.Agent"
|
||||
xmlns:services="using:ClaudeDo.Ui.Services"
|
||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
xmlns:locm="using:ClaudeDo.Localization"
|
||||
@@ -108,6 +110,19 @@
|
||||
<ComboBoxItem Content="{loc:Tr settings.general.weekdaySaturday}"/>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.sessionSkills}"/>
|
||||
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="{loc:Tr settings.general.sessionSkillsHint}"/>
|
||||
<ItemsControl ItemsSource="{Binding General.SessionSkills}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="agent:SelectableSkillViewModel">
|
||||
<CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay}" Content="{Binding Name}"
|
||||
ToolTip.Tip="{Binding Description}"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
@@ -354,6 +369,58 @@
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="{loc:Tr settings.tabSkills}">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="12" Margin="0,8,0,0">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr settings.skills.installSection}"/>
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
|
||||
<TextBox Grid.Column="0" Text="{Binding SessionSkills.InstallUrl, Mode=TwoWay}"
|
||||
PlaceholderText="{loc:Tr settings.skills.installUrlPlaceholder}"/>
|
||||
<Button Grid.Column="1" Classes="btn" Content="{loc:Tr settings.skills.installButton}"
|
||||
Command="{Binding SessionSkills.InstallCommand}"
|
||||
IsEnabled="{Binding !SessionSkills.IsBusy}"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,2,0,0"/>
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr settings.skills.installedSection}"/>
|
||||
<ItemsControl ItemsSource="{Binding SessionSkills.Skills}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="services:SessionSkillDto">
|
||||
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="10,8" Margin="0,0,0,8"
|
||||
Background="{DynamicResource DeepBrush}">
|
||||
<StackPanel Spacing="4">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="title" Text="{Binding Name}"/>
|
||||
<Button Grid.Column="1" Classes="btn" Content="{loc:Tr settings.skills.updateButton}"
|
||||
Margin="0,0,6,0"
|
||||
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).SessionSkills.UpdateCommand}"
|
||||
CommandParameter="{Binding SourceUrl}"/>
|
||||
<Button Grid.Column="2" Classes="btn danger" Content="{loc:Tr settings.skills.removeButton}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).SessionSkills.RemoveCommand}"
|
||||
CommandParameter="{Binding SourceUrl}"/>
|
||||
</Grid>
|
||||
<TextBlock Classes="meta" Text="{Binding Description}" TextWrapping="Wrap"
|
||||
IsVisible="{Binding Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<TextBlock Classes="meta" Opacity="0.6" Text="{Binding PinnedRef}"/>
|
||||
<TextBlock Classes="path-mono" Text="{Binding SourceUrl}" TextTrimming="PrefixCharacterEllipsis"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="meta" Text="{Binding SessionSkills.StatusMessage}"
|
||||
IsVisible="{Binding SessionSkills.StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
|
||||
|
||||
@@ -14,12 +14,14 @@ public class AgentConfigEditorViewModelTests
|
||||
public AppSettingsDto? App;
|
||||
public ListConfigDto? ListCfg;
|
||||
public List<AgentInfo> AgentList = new();
|
||||
public List<SessionSkillDto> InstalledSkills = new();
|
||||
public UpdateListConfigDto? SavedListConfig;
|
||||
public UpdateTaskAgentSettingsDto? SavedTaskSettings;
|
||||
|
||||
public override Task<AppSettingsDto?> GetAppSettingsAsync() => Task.FromResult(App);
|
||||
public override Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult(ListCfg);
|
||||
public override Task<List<AgentInfo>> GetAgentsAsync() => Task.FromResult(AgentList);
|
||||
public override Task<List<SessionSkillDto>> GetSessionSkillsAsync() => Task.FromResult(InstalledSkills);
|
||||
public override Task UpdateListConfigAsync(UpdateListConfigDto dto) { SavedListConfig = dto; return Task.CompletedTask; }
|
||||
public override Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) { SavedTaskSettings = dto; return Task.CompletedTask; }
|
||||
}
|
||||
@@ -186,4 +188,102 @@ public class AgentConfigEditorViewModelTests
|
||||
await vm.SaveAsync();
|
||||
Assert.Null(w.SavedTaskSettings);
|
||||
}
|
||||
|
||||
// ── Session skills ──────────────────────────────────────────────────────
|
||||
|
||||
private static SessionSkillDto Skill(string name) => new(name, "desc-" + name, "url-" + name, "ref", DateTimeOffset.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public async Task List_load_reflects_selection_from_list_config()
|
||||
{
|
||||
var w = new FakeWorker
|
||||
{
|
||||
App = AppWith("haiku", 50),
|
||||
ListCfg = new ListConfigDto(null, null, null, null, new List<string> { "skill-b" }),
|
||||
InstalledSkills = new() { Skill("skill-a"), Skill("skill-b") },
|
||||
};
|
||||
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.List);
|
||||
|
||||
await vm.LoadForListAsync("l1");
|
||||
|
||||
Assert.Equal(2, vm.SessionSkills.Count);
|
||||
Assert.False(vm.SessionSkills.Single(s => s.Name == "skill-a").IsSelected);
|
||||
Assert.True(vm.SessionSkills.Single(s => s.Name == "skill-b").IsSelected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_save_includes_selected_skill_names()
|
||||
{
|
||||
var w = new FakeWorker
|
||||
{
|
||||
App = AppWith("haiku", 50),
|
||||
ListCfg = new ListConfigDto(null, null, null, null, null),
|
||||
InstalledSkills = new() { Skill("skill-a"), Skill("skill-b") },
|
||||
};
|
||||
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.List);
|
||||
await vm.LoadForListAsync("l1");
|
||||
|
||||
vm.SessionSkills.Single(s => s.Name == "skill-a").IsSelected = true;
|
||||
await vm.SaveAsync();
|
||||
|
||||
Assert.NotNull(w.SavedListConfig);
|
||||
Assert.Equal(new List<string> { "skill-a" }, w.SavedListConfig!.SessionSkills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Task_load_parses_session_skills_json_and_reflects_selection()
|
||||
{
|
||||
var w = new FakeWorker
|
||||
{
|
||||
App = AppWith("haiku", 50),
|
||||
ListCfg = new ListConfigDto(null, null, null, null),
|
||||
InstalledSkills = new() { Skill("skill-a"), Skill("skill-b") },
|
||||
};
|
||||
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.Task);
|
||||
var entity = TaskWith(null, null, "", null);
|
||||
entity.SessionSkills = "[\"skill-a\"]";
|
||||
|
||||
await vm.LoadForTaskAsync(entity);
|
||||
|
||||
Assert.True(vm.SessionSkills.Single(s => s.Name == "skill-a").IsSelected);
|
||||
Assert.False(vm.SessionSkills.Single(s => s.Name == "skill-b").IsSelected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Task_toggling_a_skill_auto_saves_selection()
|
||||
{
|
||||
var w = new FakeWorker
|
||||
{
|
||||
App = AppWith("haiku", 50),
|
||||
ListCfg = new ListConfigDto(null, null, null, null),
|
||||
InstalledSkills = new() { Skill("skill-a") },
|
||||
};
|
||||
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.Task);
|
||||
await vm.LoadForTaskAsync(TaskWith(null, null, "", null));
|
||||
|
||||
vm.SessionSkills.Single(s => s.Name == "skill-a").IsSelected = true;
|
||||
await Task.Delay(500);
|
||||
|
||||
Assert.NotNull(w.SavedTaskSettings);
|
||||
Assert.Equal(new List<string> { "skill-a" }, w.SavedTaskSettings!.SessionSkills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clear_deselects_all_skills()
|
||||
{
|
||||
var w = new FakeWorker
|
||||
{
|
||||
App = AppWith("haiku", 50),
|
||||
ListCfg = new ListConfigDto(null, null, null, null),
|
||||
InstalledSkills = new() { Skill("skill-a") },
|
||||
};
|
||||
var vm = new AgentConfigEditorViewModel(w, AgentConfigScope.Task);
|
||||
var entity = TaskWith(null, null, "", null);
|
||||
entity.SessionSkills = "[\"skill-a\"]";
|
||||
await vm.LoadForTaskAsync(entity);
|
||||
|
||||
vm.Clear();
|
||||
|
||||
Assert.All(vm.SessionSkills, s => Assert.False(s.IsSelected));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class GeneralSettingsTabViewModelTests
|
||||
{
|
||||
private sealed class FakeWorker : StubWorkerClient
|
||||
{
|
||||
public List<SessionSkillDto> Installed = new();
|
||||
public override Task<List<SessionSkillDto>> GetSessionSkillsAsync() => Task.FromResult(Installed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSessionSkillsAsync_reflects_current_selection()
|
||||
{
|
||||
var w = new FakeWorker
|
||||
{
|
||||
Installed = new() { new SessionSkillDto("a", "", "url-a", "ref", DateTimeOffset.UtcNow),
|
||||
new SessionSkillDto("b", "", "url-b", "ref", DateTimeOffset.UtcNow) },
|
||||
};
|
||||
var vm = new GeneralSettingsTabViewModel();
|
||||
|
||||
await vm.LoadSessionSkillsAsync(w, new List<string> { "b" });
|
||||
|
||||
Assert.Equal(2, vm.SessionSkills.Count);
|
||||
Assert.False(vm.SessionSkills.Single(s => s.Name == "a").IsSelected);
|
||||
Assert.True(vm.SessionSkills.Single(s => s.Name == "b").IsSelected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelectedSessionSkillNames_round_trips_through_toggle()
|
||||
{
|
||||
var w = new FakeWorker
|
||||
{
|
||||
Installed = new() { new SessionSkillDto("a", "", "url-a", "ref", DateTimeOffset.UtcNow) },
|
||||
};
|
||||
var vm = new GeneralSettingsTabViewModel();
|
||||
await vm.LoadSessionSkillsAsync(w, null);
|
||||
|
||||
Assert.Null(vm.SelectedSessionSkillNames());
|
||||
|
||||
vm.SessionSkills.Single().IsSelected = true;
|
||||
|
||||
Assert.Equal(new List<string> { "a" }, vm.SelectedSessionSkillNames());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class SessionSkillsSettingsTabViewModelTests
|
||||
{
|
||||
private sealed class FakeWorker : StubWorkerClient
|
||||
{
|
||||
public List<SessionSkillDto> Installed = new();
|
||||
public string? InstallUrlReceived;
|
||||
public string? UpdatedSourceUrl;
|
||||
public string? RemovedSourceUrl;
|
||||
public Exception? InstallError;
|
||||
|
||||
public override Task<List<SessionSkillDto>> GetSessionSkillsAsync() => Task.FromResult(Installed);
|
||||
|
||||
public override Task<List<string>> InstallSessionSkillAsync(string url)
|
||||
{
|
||||
if (InstallError is not null) throw InstallError;
|
||||
InstallUrlReceived = url;
|
||||
Installed = Installed.Append(new SessionSkillDto("new-skill", "desc", url, "abc123", DateTimeOffset.UtcNow)).ToList();
|
||||
return Task.FromResult(new List<string> { "new-skill" });
|
||||
}
|
||||
|
||||
public override Task UpdateSessionSkillAsync(string sourceUrl) { UpdatedSourceUrl = sourceUrl; return Task.CompletedTask; }
|
||||
public override Task RemoveSessionSkillAsync(string sourceUrl)
|
||||
{
|
||||
RemovedSourceUrl = sourceUrl;
|
||||
Installed = Installed.Where(s => s.SourceUrl != sourceUrl).ToList();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_populates_skills_from_worker()
|
||||
{
|
||||
var w = new FakeWorker { Installed = new() { new SessionSkillDto("a", "desc-a", "url-a", "ref-a", DateTimeOffset.UtcNow) } };
|
||||
var vm = new SessionSkillsSettingsTabViewModel(w);
|
||||
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.Single(vm.Skills);
|
||||
Assert.Equal("a", vm.Skills[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InstallAsync_installs_then_refreshes_and_sets_status()
|
||||
{
|
||||
var w = new FakeWorker();
|
||||
var vm = new SessionSkillsSettingsTabViewModel(w) { InstallUrl = "https://example.com/repo.git" };
|
||||
|
||||
await vm.InstallCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal("https://example.com/repo.git", w.InstallUrlReceived);
|
||||
Assert.Single(vm.Skills);
|
||||
Assert.Equal("new-skill", vm.Skills[0].Name);
|
||||
Assert.Contains("new-skill", vm.StatusMessage);
|
||||
Assert.Equal("", vm.InstallUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InstallAsync_surfaces_exception_message_on_failure()
|
||||
{
|
||||
// The worker actually throws a HubException on a name collision; any Exception with a
|
||||
// readable Message exercises the same catch-and-surface path in the view model.
|
||||
var w = new FakeWorker { InstallError = new InvalidOperationException("Skill 'foo' already installed from a different source.") };
|
||||
var vm = new SessionSkillsSettingsTabViewModel(w) { InstallUrl = "https://example.com/dup.git" };
|
||||
|
||||
await vm.InstallCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Contains("already installed", vm.StatusMessage);
|
||||
Assert.Empty(vm.Skills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InstallAsync_does_nothing_for_blank_url()
|
||||
{
|
||||
var w = new FakeWorker();
|
||||
var vm = new SessionSkillsSettingsTabViewModel(w) { InstallUrl = " " };
|
||||
|
||||
await vm.InstallCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Null(w.InstallUrlReceived);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveAsync_removes_then_refreshes()
|
||||
{
|
||||
var w = new FakeWorker { Installed = new() { new SessionSkillDto("a", "", "url-a", "ref-a", DateTimeOffset.UtcNow) } };
|
||||
var vm = new SessionSkillsSettingsTabViewModel(w);
|
||||
await vm.LoadAsync();
|
||||
|
||||
await vm.RemoveCommand.ExecuteAsync("url-a");
|
||||
|
||||
Assert.Equal("url-a", w.RemovedSourceUrl);
|
||||
Assert.Empty(vm.Skills);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_calls_worker_then_refreshes()
|
||||
{
|
||||
var w = new FakeWorker { Installed = new() { new SessionSkillDto("a", "", "url-a", "ref-a", DateTimeOffset.UtcNow) } };
|
||||
var vm = new SessionSkillsSettingsTabViewModel(w);
|
||||
await vm.LoadAsync();
|
||||
|
||||
await vm.UpdateCommand.ExecuteAsync("url-a");
|
||||
|
||||
Assert.Equal("url-a", w.UpdatedSourceUrl);
|
||||
Assert.NotEmpty(vm.StatusMessage);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user