chore(claude-do): merge feat(ui): Settings-Modal auf Sidebar-Kategorien umbauen (UX-

ClaudeDo-Task: 314cf7a5-ed81-4b55-895c-abc30301d02e
This commit is contained in:
mika kuns
2026-08-21 15:08:32 +02:00
10 changed files with 548 additions and 209 deletions
+15
View File
@@ -10,6 +10,21 @@
"tabFiles": "Dateien",
"tabPrime": "Prime Claude",
"tabSkills": "Skills",
"sidebar": {
"groupBasis": "BASIS",
"groupAdvanced": "ERWEITERT",
"categoryGeneral": "Allgemein",
"categoryExecution": "Ausführung",
"categoryPrime": "Prime Claude",
"categoryWorktrees": "Worktrees",
"categoryFiles": "Dateien & Prompts",
"categorySkills": "Session Skills",
"categoryReports": "Berichte"
},
"repoHint": {
"text": "Wirkt erst mit verknüpftem Repo.",
"link": "Repo importieren"
},
"general": {
"defaultInstructions": "Standard-Anweisungen",
"defaultInstructionsPlaceholder": "Basis-Anweisungen, die auf jede Aufgabe angewendet werden",
+15
View File
@@ -10,6 +10,21 @@
"tabFiles": "Files",
"tabPrime": "Prime Claude",
"tabSkills": "Skills",
"sidebar": {
"groupBasis": "BASICS",
"groupAdvanced": "ADVANCED",
"categoryGeneral": "General",
"categoryExecution": "Execution",
"categoryPrime": "Prime Claude",
"categoryWorktrees": "Worktrees",
"categoryFiles": "Files & Prompts",
"categorySkills": "Session Skills",
"categoryReports": "Reports"
},
"repoHint": {
"text": "Only takes effect once a repo is linked.",
"link": "Import repo"
},
"general": {
"defaultInstructions": "Default instructions",
"defaultInstructionsPlaceholder": "Baseline instructions applied to every task",
@@ -3,7 +3,6 @@ using ClaudeDo.Data.Models;
using ClaudeDo.Localization;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Agent;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -33,8 +32,6 @@ 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 ObservableCollection<AccentPresetSwatchViewModel> AccentPresetSwatches { get; } = new();
private Action<string>? _persistAccent;
@@ -109,22 +106,6 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
return $"Max turns for {row.Model} must be between 1 and 200.";
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;
}
}
/// <summary>One row of the per-model preset table. Single consumer, so it lives here.</summary>
@@ -18,7 +18,7 @@ public sealed partial class SessionSkillsSettingsTabViewModel : ViewModelBase
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private bool _isEmpty = true;
public ObservableCollection<SessionSkillDto> Skills { get; } = new();
public ObservableCollection<SessionSkillRowViewModel> Skills { get; } = new();
public SessionSkillsSettingsTabViewModel(IWorkerClient worker)
{
@@ -33,19 +33,31 @@ public sealed partial class SessionSkillsSettingsTabViewModel : ViewModelBase
};
}
public async Task LoadAsync()
/// <summary>Loads the installed skills, marking each row selected per <paramref name="selectedNames"/>.
/// When omitted, the current selection is preserved across the refresh (e.g. after install/update/remove).</summary>
public async Task LoadAsync(IReadOnlyCollection<string>? selectedNames = null)
{
IsBusy = true;
try
{
var selected = selectedNames ?? Skills.Where(s => s.IsSelected).Select(s => s.Name).ToList();
var selectedSet = new HashSet<string>(selected);
var skills = await _worker.GetSessionSkillsAsync();
Skills.Clear();
foreach (var s in skills) Skills.Add(s);
foreach (var s in skills) Skills.Add(new SessionSkillRowViewModel(s, selectedSet.Contains(s.Name)));
IsEmpty = Skills.Count == 0;
}
finally { IsBusy = false; }
}
/// <summary>The globally-selected skill names, or null when none are selected (matches the
/// AppSettingsDto.SessionSkills convention of "null means no global selection").</summary>
public List<string>? SelectedSkillNames()
{
var names = Skills.Where(s => s.IsSelected).Select(s => s.Name).ToList();
return names.Count == 0 ? null : names;
}
private bool CanInstall() => !InstallOp.IsRunning;
[RelayCommand(CanExecute = nameof(CanInstall))]
@@ -96,3 +108,27 @@ public sealed partial class SessionSkillsSettingsTabViewModel : ViewModelBase
finally { IsBusy = false; }
}
}
/// <summary>One installed skill row: identity/management fields from <see cref="SessionSkillDto"/>
/// plus the global "active for every task" checkbox state that used to live in a separate
/// General-tab list.</summary>
public sealed partial class SessionSkillRowViewModel : ViewModelBase
{
public string Name { get; }
public string Description { get; }
public string SourceUrl { get; }
public string PinnedRef { get; }
public DateTimeOffset AddedAt { get; }
[ObservableProperty] private bool _isSelected;
public SessionSkillRowViewModel(SessionSkillDto dto, bool isSelected)
{
Name = dto.Name;
Description = dto.Description;
SourceUrl = dto.SourceUrl;
PinnedRef = dto.PinnedRef;
AddedAt = dto.AddedAt;
_isSelected = isSelected;
}
}
@@ -6,12 +6,19 @@ using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels.Modals;
/// <summary>One sidebar entry. <c>Group</c> is an internal split key ("Basis"/"Erweitert"), not
/// user-facing text — the sidebar renders the two groups as separate lists with their own
/// (localized) heading. <c>RequiresRepo</c> marks the categories that show the repo-hint strip.</summary>
public sealed record SettingsCategory(string Key, LocalizedString Label, string Group, bool IsVisible, bool RequiresRepo);
public sealed partial class SettingsModalViewModel : ViewModelBase
{
private readonly IWorkerClient _worker;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
public GeneralSettingsTabViewModel General { get; }
public WorktreesSettingsTabViewModel Worktrees { get; }
@@ -23,6 +30,40 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
// Online Inbox ist unfertig und vor Usern verborgen — hier auf true für Reaktivierung.
public bool ShowOnlineInbox => false;
// Fixed order — mirrors the TabItem order in SettingsModalView.axaml 1:1, so
// _categories.IndexOf(category) is a valid TabControl.SelectedIndex.
private readonly List<SettingsCategory> _categories;
public IReadOnlyList<SettingsCategory> Categories => _categories;
public IReadOnlyList<SettingsCategory> BasisCategories { get; }
public IReadOnlyList<SettingsCategory> ErweitertCategories { get; }
private SettingsCategory? _selectedCategory;
public SettingsCategory? SelectedCategory
{
get => _selectedCategory;
set
{
// The two sidebar ListBoxes share this property; selecting an item in one pushes null
// into the other's binding (item not found in its own ItemsSource) — ignore that noise
// instead of letting it clobber the real selection.
if (value is null) return;
if (SetProperty(ref _selectedCategory, value))
SelectedIndex = _categories.IndexOf(value);
}
}
[ObservableProperty] private int _selectedIndex;
// True once at least one list has a linked repo (WorkingDir). Drives the repo-hint strip on
// Worktrees/Prime Claude/Session Skills/Berichte — nothing is ever hidden, just annotated.
[ObservableProperty] private bool _hasLinkedRepo;
// Wired by WindowDialogService to close this modal and open the existing repo-import flow.
public Action? RequestRepoImport { get; set; }
[RelayCommand]
private void OpenRepoImport() => RequestRepoImport?.Invoke();
[ObservableProperty] private string _validationError = "";
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _statusMessage = "";
@@ -31,9 +72,11 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
public SettingsModalViewModel(IWorkerClient worker, PrimeClaudeTabViewModel prime,
IOnlineLoginService onlineLoginService,
ILocalizer localizer, AppSettings appSettings)
ILocalizer localizer, AppSettings appSettings,
IDbContextFactory<ClaudeDoDbContext> dbFactory)
{
_worker = worker;
_dbFactory = dbFactory;
General = new GeneralSettingsTabViewModel(localizer, code =>
{
appSettings.Language = code;
@@ -49,6 +92,21 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
Prime = prime;
OnlineInbox = new OnlineInboxSettingsViewModel(worker, onlineLoginService);
SessionSkills = new SessionSkillsSettingsTabViewModel(worker);
_categories = new List<SettingsCategory>
{
new("General", new LocalizedString(localizer, "settings.sidebar.categoryGeneral"), "Basis", true, false),
new("Execution", new LocalizedString(localizer, "settings.sidebar.categoryExecution"), "Basis", true, false),
new("Prime", new LocalizedString(localizer, "settings.sidebar.categoryPrime"), "Basis", true, true),
new("Worktrees", new LocalizedString(localizer, "settings.sidebar.categoryWorktrees"), "Erweitert", true, true),
new("Files", new LocalizedString(localizer, "settings.sidebar.categoryFiles"), "Erweitert", true, false),
new("SessionSkills", new LocalizedString(localizer, "settings.sidebar.categorySkills"), "Erweitert", true, true),
new("Reports", new LocalizedString(localizer, "settings.sidebar.categoryReports"), "Erweitert", true, true),
new("OnlineInbox", new LocalizedString(localizer, "settings.onlineInbox.tabHeader"), "Erweitert", ShowOnlineInbox, false),
};
BasisCategories = _categories.Where(c => c.Group == "Basis").ToList();
ErweitertCategories = _categories.Where(c => c.Group == "Erweitert").ToList();
_selectedCategory = _categories[0];
}
// Throttle stages are edited by dragging the usage-monitor gauges, not on any Settings tab —
@@ -89,9 +147,11 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
await Prime.LoadAsync();
await OnlineInbox.LoadAsync();
await SessionSkills.LoadAsync();
await General.LoadSessionSkillsAsync(_worker, dto?.SessionSkills);
await SessionSkills.LoadAsync(dto?.SessionSkills);
General.LoadModelPresets(dto?.ModelPresets, General.MaxTurnsCeiling);
await using var ctx = await _dbFactory.CreateDbContextAsync();
HasLinkedRepo = await ctx.Lists.AnyAsync(l => l.WorkingDir != null && l.WorkingDir != "");
}
finally { IsBusy = false; }
}
@@ -121,7 +181,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
.Split('\n').Select(l => l.Trim().TrimEnd('\r')).Where(l => l.Length > 0).ToList()),
General.StandupWeekday,
Prime.DailyPrepMaxTasks,
General.SelectedSessionSkillNames(),
SessionSkills.SelectedSkillNames(),
General.ModelPresetDtos(),
General.UsageGateFiveHourPct,
General.UsageGateSevenDayPct,
@@ -11,7 +11,7 @@
x:Class="ClaudeDo.Ui.Views.Modals.SettingsModalView"
x:DataType="vm:SettingsModalViewModel"
Title="{loc:Tr settings.title}"
Width="580" Height="760" MinWidth="480" MinHeight="520"
Width="700" Height="760" MinWidth="600" MinHeight="520"
CanResize="True"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
@@ -35,7 +35,7 @@
</StackPanel>
</ctl:ModalShell.Footer>
<!-- Body: tabs + bottom validation/status strip -->
<!-- Body: sidebar + tab-content host + bottom validation/status strip -->
<DockPanel>
<StackPanel DockPanel.Dock="Bottom" Margin="20,0,20,8" Spacing="2">
<TextBlock Classes="meta" Text="{Binding ValidationError}"
@@ -45,9 +45,58 @@
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<TabControl Padding="20,16" TabStripPlacement="Top">
<Grid ColumnDefinitions="170,1,*">
<TabItem Header="{loc:Tr settings.tabGeneral}">
<!-- Sidebar: two independently-scrolled groups sharing one SelectedCategory. -->
<StackPanel Grid.Column="0" Spacing="16" Margin="12,16,8,8">
<StackPanel Spacing="2">
<TextBlock Classes="eyebrow" Opacity="0.6" Margin="10,0,0,4"
Text="{loc:Tr settings.sidebar.groupBasis}"/>
<ListBox ItemsSource="{Binding BasisCategories}"
SelectedItem="{Binding SelectedCategory, Mode=TwoWay}"
Background="Transparent">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:SettingsCategory">
<TextBlock Text="{Binding Label.Value}"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
<StackPanel Spacing="2">
<TextBlock Classes="eyebrow" Opacity="0.6" Margin="10,0,0,4"
Text="{loc:Tr settings.sidebar.groupAdvanced}"/>
<ListBox ItemsSource="{Binding ErweitertCategories}"
SelectedItem="{Binding SelectedCategory, Mode=TwoWay}"
Background="Transparent">
<ListBox.Styles>
<Style Selector="ListBoxItem" x:DataType="vm:SettingsCategory">
<Setter Property="IsVisible" Value="{Binding IsVisible}"/>
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:SettingsCategory">
<TextBlock Text="{Binding Label.Value}"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
</StackPanel>
<Border Grid.Column="1" Background="{DynamicResource LineBrush}" Width="1" Margin="0,8"/>
<!-- TabControl stays the content host (SelectedIndex driven by the sidebar); its own
header strip is re-templated away so only PART_SelectedContentHost renders. -->
<TabControl Grid.Column="2" Padding="20,16" SelectedIndex="{Binding SelectedIndex, Mode=OneWay}">
<TabControl.Template>
<ControlTemplate TargetType="TabControl">
<ContentPresenter Name="PART_SelectedContentHost"
Content="{TemplateBinding SelectedContent}"
ContentTemplate="{TemplateBinding SelectedContentTemplate}"/>
</ControlTemplate>
</TabControl.Template>
<!-- Allgemein: language, accent, default instructions. -->
<TabItem Header="{loc:Tr settings.sidebar.categoryGeneral}">
<ScrollViewer>
<StackPanel Spacing="12" Margin="0,8,0,0">
<StackPanel Spacing="4">
@@ -101,6 +150,14 @@
PlaceholderText="{loc:Tr settings.general.defaultInstructionsPlaceholder}"
Text="{Binding General.DefaultClaudeInstructions, Mode=TwoWay}"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</TabItem>
<!-- Ausführung: model/permission, per-model presets, parallelism, usage gate. -->
<TabItem Header="{loc:Tr settings.sidebar.categoryExecution}">
<ScrollViewer>
<StackPanel Spacing="12" Margin="0,8,0,0">
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.model}"/>
@@ -181,42 +238,83 @@
</StackPanel>
</StackPanel>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.reportExcludedPaths}"/>
<TextBox AcceptsReturn="True" MinHeight="60" Text="{Binding General.ReportExcludedPaths, Mode=TwoWay}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.standupWeekday}"/>
<ComboBox SelectedIndex="{Binding General.StandupWeekday, Mode=TwoWay}" HorizontalAlignment="Left">
<ComboBoxItem Content="{loc:Tr settings.general.weekdaySunday}"/>
<ComboBoxItem Content="{loc:Tr settings.general.weekdayMonday}"/>
<ComboBoxItem Content="{loc:Tr settings.general.weekdayTuesday}"/>
<ComboBoxItem Content="{loc:Tr settings.general.weekdayWednesday}"/>
<ComboBoxItem Content="{loc:Tr settings.general.weekdayThursday}"/>
<ComboBoxItem Content="{loc:Tr settings.general.weekdayFriday}"/>
<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>
</ScrollViewer>
</TabItem>
<TabItem Header="{loc:Tr settings.sidebar.categoryPrime}">
<ScrollViewer>
<StackPanel Spacing="12" Margin="0,8,0,0">
<Border IsVisible="{Binding !HasLinkedRepo}"
Background="{DynamicResource DeepBrush}" BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1" CornerRadius="6" Padding="10,8">
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock Classes="meta" Text="{loc:Tr settings.repoHint.text}" VerticalAlignment="Center"/>
<Button Classes="btn" Content="{loc:Tr settings.repoHint.link}" Command="{Binding OpenRepoImportCommand}"/>
</StackPanel>
</Border>
<TextBlock Classes="meta" TextWrapping="Wrap"
Text="{loc:Tr settings.prime.description}"/>
<ItemsControl ItemsSource="{Binding Prime.Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="settings:PrimeScheduleRowViewModel">
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1"
CornerRadius="6" Padding="10,8" Margin="0,0,0,8"
Background="{DynamicResource DeepBrush}">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" ColumnSpacing="8">
<CheckBox Grid.Column="0" IsChecked="{Binding Enabled, Mode=TwoWay}" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.dayMo}" IsChecked="{Binding Monday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.dayTu}" IsChecked="{Binding Tuesday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.dayWe}" IsChecked="{Binding Wednesday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.dayTh}" IsChecked="{Binding Thursday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.dayFr}" IsChecked="{Binding Friday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.daySa}" IsChecked="{Binding Saturday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.daySu}" IsChecked="{Binding Sunday, Mode=TwoWay}"/>
</StackPanel>
<TextBox Grid.Column="2"
Text="{Binding TimeText, Mode=TwoWay}"
PlaceholderText="HH:mm" MaxLength="5"
Width="68" VerticalAlignment="Center"/>
<TextBlock Classes="meta" Grid.Column="3" Text="{Binding LastRunLabel}" VerticalAlignment="Center"
MinWidth="80"/>
<Button Classes="icon-btn" Grid.Column="4" Content="✕"
ToolTip.Tip="{loc:Tr settings.prime.removeScheduleTip}"
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).Prime.RemoveScheduleCommand}"
CommandParameter="{Binding}"/>
</Grid>
<TextBlock Classes="field-label" Text="{loc:Tr settings.prime.promptOverrideLabel}"/>
<TextBox Text="{Binding PromptOverride, Mode=TwoWay}"
PlaceholderText="{loc:Tr settings.prime.promptOverridePlaceholder}"
AcceptsReturn="True" TextWrapping="Wrap"
MinHeight="48" MaxHeight="120"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="btn" Content="{loc:Tr settings.prime.addSchedule}" Command="{Binding Prime.AddScheduleCommand}" HorizontalAlignment="Left"/>
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock Classes="field-label" Text="{loc:Tr settings.prime.dailyPrepMaxTasks}" VerticalAlignment="Center"/>
<NumericUpDown Minimum="1" Maximum="50" Increment="1" Width="100" FormatString="0"
Value="{Binding Prime.DailyPrepMaxTasks, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem Header="{loc:Tr settings.tabWorktrees}">
<TabItem Header="{loc:Tr settings.sidebar.categoryWorktrees}">
<ScrollViewer>
<StackPanel Spacing="12" Margin="0,8,0,0">
<Border IsVisible="{Binding !HasLinkedRepo}"
Background="{DynamicResource DeepBrush}" BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1" CornerRadius="6" Padding="10,8">
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock Classes="meta" Text="{loc:Tr settings.repoHint.text}" VerticalAlignment="Center"/>
<Button Classes="btn" Content="{loc:Tr settings.repoHint.link}" Command="{Binding OpenRepoImportCommand}"/>
</StackPanel>
</Border>
<Grid ColumnDefinitions="*,12,2*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.worktrees.strategy}"/>
@@ -258,7 +356,7 @@
</ScrollViewer>
</TabItem>
<TabItem Header="{loc:Tr settings.tabFiles}">
<TabItem Header="{loc:Tr settings.sidebar.categoryFiles}">
<ScrollViewer>
<StackPanel Spacing="12" Margin="0,8,0,0">
<StackPanel Spacing="6">
@@ -332,55 +430,104 @@
</ScrollViewer>
</TabItem>
<TabItem Header="{loc:Tr settings.tabPrime}">
<!-- Session Skills: merged install/manage list (was the "Skills" tab) plus the global
active-selection checkbox that used to be a separate list on the General tab. -->
<TabItem Header="{loc:Tr settings.sidebar.categorySkills}">
<ScrollViewer>
<StackPanel Spacing="12" Margin="0,8,0,0">
<TextBlock Classes="meta" TextWrapping="Wrap"
Text="{loc:Tr settings.prime.description}"/>
<ItemsControl ItemsSource="{Binding Prime.Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="settings:PrimeScheduleRowViewModel">
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1"
CornerRadius="6" Padding="10,8" Margin="0,0,0,8"
Background="{DynamicResource DeepBrush}">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" ColumnSpacing="8">
<CheckBox Grid.Column="0" IsChecked="{Binding Enabled, Mode=TwoWay}" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.dayMo}" IsChecked="{Binding Monday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.dayTu}" IsChecked="{Binding Tuesday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.dayWe}" IsChecked="{Binding Wednesday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.dayTh}" IsChecked="{Binding Thursday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.dayFr}" IsChecked="{Binding Friday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.daySa}" IsChecked="{Binding Saturday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="{loc:Tr settings.prime.daySu}" IsChecked="{Binding Sunday, Mode=TwoWay}"/>
</StackPanel>
<TextBox Grid.Column="2"
Text="{Binding TimeText, Mode=TwoWay}"
PlaceholderText="HH:mm" MaxLength="5"
Width="68" VerticalAlignment="Center"/>
<TextBlock Classes="meta" Grid.Column="3" Text="{Binding LastRunLabel}" VerticalAlignment="Center"
MinWidth="80"/>
<Button Classes="icon-btn" Grid.Column="4" Content="✕"
ToolTip.Tip="{loc:Tr settings.prime.removeScheduleTip}"
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).Prime.RemoveScheduleCommand}"
CommandParameter="{Binding}"/>
</Grid>
<TextBlock Classes="field-label" Text="{loc:Tr settings.prime.promptOverrideLabel}"/>
<TextBox Text="{Binding PromptOverride, Mode=TwoWay}"
PlaceholderText="{loc:Tr settings.prime.promptOverridePlaceholder}"
AcceptsReturn="True" TextWrapping="Wrap"
MinHeight="48" MaxHeight="120"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="btn" Content="{loc:Tr settings.prime.addSchedule}" Command="{Binding Prime.AddScheduleCommand}" HorizontalAlignment="Left"/>
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock Classes="field-label" Text="{loc:Tr settings.prime.dailyPrepMaxTasks}" VerticalAlignment="Center"/>
<NumericUpDown Minimum="1" Maximum="50" Increment="1" Width="100" FormatString="0"
Value="{Binding Prime.DailyPrepMaxTasks, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"/>
<Border IsVisible="{Binding !HasLinkedRepo}"
Background="{DynamicResource DeepBrush}" BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1" CornerRadius="6" Padding="10,8">
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock Classes="meta" Text="{loc:Tr settings.repoHint.text}" VerticalAlignment="Center"/>
<Button Classes="btn" Content="{loc:Tr settings.repoHint.link}" Command="{Binding OpenRepoImportCommand}"/>
</StackPanel>
</Border>
<StackPanel Spacing="6">
<TextBlock Classes="section-label" Text="{loc:Tr settings.skills.installedSection}"/>
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
Text="{loc:Tr settings.general.sessionSkillsHint}"/>
<ctl:OperationIndicator Status="{Binding SessionSkills.UpdateOp}"/>
<ItemsControl ItemsSource="{Binding SessionSkills.Skills}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="settings:SessionSkillRowViewModel">
<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,Auto">
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected, Mode=TwoWay}"
VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBlock Grid.Column="1" Classes="title" Text="{Binding Name}" VerticalAlignment="Center"/>
<Button Grid.Column="2" 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="3" 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>
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
Text="{loc:Tr settings.skills.emptyState}"
IsVisible="{Binding SessionSkills.IsEmpty}"/>
</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.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}"/>
</Grid>
<ctl:OperationIndicator Status="{Binding SessionSkills.InstallOp}"/>
</StackPanel>
<TextBlock Classes="meta" Text="{Binding SessionSkills.StatusMessage}"
IsVisible="{Binding SessionSkills.StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</ScrollViewer>
</TabItem>
<!-- Berichte: standup weekday + weekly-report excluded paths (split out of General). -->
<TabItem Header="{loc:Tr settings.sidebar.categoryReports}">
<ScrollViewer>
<StackPanel Spacing="12" Margin="0,8,0,0">
<Border IsVisible="{Binding !HasLinkedRepo}"
Background="{DynamicResource DeepBrush}" BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1" CornerRadius="6" Padding="10,8">
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock Classes="meta" Text="{loc:Tr settings.repoHint.text}" VerticalAlignment="Center"/>
<Button Classes="btn" Content="{loc:Tr settings.repoHint.link}" Command="{Binding OpenRepoImportCommand}"/>
</StackPanel>
</Border>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.standupWeekday}"/>
<ComboBox SelectedIndex="{Binding General.StandupWeekday, Mode=TwoWay}" HorizontalAlignment="Left">
<ComboBoxItem Content="{loc:Tr settings.general.weekdaySunday}"/>
<ComboBoxItem Content="{loc:Tr settings.general.weekdayMonday}"/>
<ComboBoxItem Content="{loc:Tr settings.general.weekdayTuesday}"/>
<ComboBoxItem Content="{loc:Tr settings.general.weekdayWednesday}"/>
<ComboBoxItem Content="{loc:Tr settings.general.weekdayThursday}"/>
<ComboBoxItem Content="{loc:Tr settings.general.weekdayFriday}"/>
<ComboBoxItem Content="{loc:Tr settings.general.weekdaySaturday}"/>
</ComboBox>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.reportExcludedPaths}"/>
<TextBox AcceptsReturn="True" MinHeight="60" Text="{Binding General.ReportExcludedPaths, Mode=TwoWay}"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
@@ -480,63 +627,8 @@
</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}"/>
</Grid>
<ctl:OperationIndicator Status="{Binding SessionSkills.InstallOp}"/>
</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}"/>
<ctl:OperationIndicator Status="{Binding SessionSkills.UpdateOp}"/>
<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>
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
Text="{loc:Tr settings.skills.emptyState}"
IsVisible="{Binding SessionSkills.IsEmpty}"/>
</StackPanel>
<TextBlock Classes="meta" Text="{Binding SessionSkills.StatusMessage}"
IsVisible="{Binding SessionSkills.StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</ScrollViewer>
</TabItem>
</TabControl>
</TabControl>
</Grid>
</DockPanel>
</ctl:ModalShell>
@@ -72,6 +72,11 @@ public sealed class WindowDialogService : IDialogService
{
vm.Worktrees.ConfirmAsync = ConfirmAsync;
var dlg = new SettingsModalView { DataContext = vm };
vm.RequestRepoImport = () =>
{
dlg.Close();
if (Shell is not null) _ = Shell.OpenRepoImportCommand.ExecuteAsync(null);
};
await dlg.ShowDialog(ActiveOwner());
}
@@ -1,4 +1,3 @@
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
using Xunit;
@@ -6,46 +5,6 @@ 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());
}
[Theory]
[InlineData(-1, false)]
[InlineData(0, true)]
@@ -180,4 +180,53 @@ public class SessionSkillsSettingsTabViewModelTests
Assert.Equal("url-a", w.UpdatedSourceUrl);
Assert.NotEmpty(vm.StatusMessage);
}
[Fact]
public async Task LoadAsync_marks_rows_selected_per_given_names()
{
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 SessionSkillsSettingsTabViewModel(w);
await vm.LoadAsync(new List<string> { "b" });
Assert.False(vm.Skills.Single(s => s.Name == "a").IsSelected);
Assert.True(vm.Skills.Single(s => s.Name == "b").IsSelected);
}
[Fact]
public async Task SelectedSkillNames_round_trips_through_toggle()
{
var w = new FakeWorker { Installed = new() { new SessionSkillDto("a", "", "url-a", "ref", DateTimeOffset.UtcNow) } };
var vm = new SessionSkillsSettingsTabViewModel(w);
await vm.LoadAsync(null);
Assert.Null(vm.SelectedSkillNames());
vm.Skills.Single().IsSelected = true;
Assert.Equal(new List<string> { "a" }, vm.SelectedSkillNames());
}
[Fact]
public async Task RefreshAfterInstall_preserves_existing_selection()
{
// Install/Update/Remove call LoadAsync() with no explicit selection — that must not
// reset the checkbox state of the skills that were already selected.
var w = new FakeWorker { Installed = new() { new SessionSkillDto("a", "", "url-a", "ref", DateTimeOffset.UtcNow) } };
var vm = new SessionSkillsSettingsTabViewModel(w) { InstallUrl = "https://example.com/repo.git" };
await vm.LoadAsync(new List<string> { "a" });
Assert.True(vm.Skills.Single(s => s.Name == "a").IsSelected);
await vm.InstallCommand.ExecuteAsync(null);
Assert.True(vm.Skills.Single(s => s.Name == "a").IsSelected);
Assert.False(vm.Skills.Single(s => s.Name == "new-skill").IsSelected);
}
}
@@ -1,20 +1,56 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Localization;
using ClaudeDo.Ui;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class SettingsModalViewModelTests
public class SettingsModalViewModelTests : IDisposable
{
private readonly string _dbPath =
Path.Combine(Path.GetTempPath(), $"claudedo_settingsmodal_test_{Guid.NewGuid():N}.db");
public SettingsModalViewModelTests()
{
using var ctx = NewContext();
ctx.Database.EnsureCreated();
}
public void Dispose()
{
try { File.Delete(_dbPath); } catch { }
try { File.Delete(_dbPath + "-wal"); } catch { }
try { File.Delete(_dbPath + "-shm"); } catch { }
}
private ClaudeDoDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={_dbPath}")
.Options;
return new ClaudeDoDbContext(opts);
}
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
{
private readonly Func<ClaudeDoDbContext> _create;
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
public ClaudeDoDbContext CreateDbContext() => _create();
}
private sealed class FakeWorker : StubWorkerClient
{
public AppSettingsDto? AppToReturn;
public AppSettingsDto? Saved;
public List<SessionSkillDto> InstalledSkills = new();
public override Task<AppSettingsDto?> GetAppSettingsAsync() => Task.FromResult(AppToReturn);
public override Task UpdateAppSettingsAsync(AppSettingsDto dto) { Saved = dto; return Task.CompletedTask; }
public override Task<List<SessionSkillDto>> GetSessionSkillsAsync() => Task.FromResult(InstalledSkills);
}
private sealed class FakePrimeApi : IPrimeScheduleApi
@@ -70,9 +106,9 @@ public class SettingsModalViewModelTests
Assert.Equal(88, worker.Saved.UsageThrottleSevenDayHardPct);
}
private static SettingsModalViewModel MakeVm(FakeWorker worker) =>
private SettingsModalViewModel MakeVm(FakeWorker worker) =>
new(worker, new PrimeClaudeTabViewModel(new FakePrimeApi()), new OnlineLoginService(),
MakeLocalizer(), new AppSettings());
MakeLocalizer(), new AppSettings(), new TestDbFactory(NewContext));
[Fact]
public async Task LoadAsync_fills_usage_gate_fields_from_dto()
@@ -100,4 +136,95 @@ public class SettingsModalViewModelTests
Assert.Equal(42, worker.Saved!.UsageGateFiveHourPct);
Assert.Equal(77, worker.Saved.UsageGateSevenDayPct);
}
[Fact]
public async Task HasLinkedRepo_false_when_no_list_has_a_working_dir()
{
using (var ctx = NewContext())
{
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "No repo", CreatedAt = DateTime.UtcNow, WorkingDir = null });
ctx.SaveChanges();
}
var vm = MakeVm(new FakeWorker());
await vm.LoadAsync();
Assert.False(vm.HasLinkedRepo);
}
[Fact]
public async Task HasLinkedRepo_true_when_a_list_has_a_working_dir()
{
using (var ctx = NewContext())
{
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "Repo", CreatedAt = DateTime.UtcNow, WorkingDir = @"C:\repo" });
ctx.SaveChanges();
}
var vm = MakeVm(new FakeWorker());
await vm.LoadAsync();
Assert.True(vm.HasLinkedRepo);
}
[Fact]
public void SelectedCategory_updates_SelectedIndex_and_ignores_null()
{
var vm = MakeVm(new FakeWorker());
var worktrees = vm.Categories.Single(c => c.Key == "Worktrees");
vm.SelectedCategory = worktrees;
Assert.Equal(vm.Categories.ToList().IndexOf(worktrees), vm.SelectedIndex);
// Simulated deselection push from the sibling sidebar ListBox — must not clobber the pick.
vm.SelectedCategory = null;
Assert.Equal(worktrees, vm.SelectedCategory);
}
[Fact]
public void Categories_default_to_first_entry_and_mark_the_repo_gated_ones()
{
var vm = MakeVm(new FakeWorker());
Assert.Equal(vm.Categories[0], vm.SelectedCategory);
Assert.True(vm.Categories.Single(c => c.Key == "Worktrees").RequiresRepo);
Assert.True(vm.Categories.Single(c => c.Key == "Prime").RequiresRepo);
Assert.True(vm.Categories.Single(c => c.Key == "SessionSkills").RequiresRepo);
Assert.True(vm.Categories.Single(c => c.Key == "Reports").RequiresRepo);
Assert.False(vm.Categories.Single(c => c.Key == "General").RequiresRepo);
Assert.False(vm.Categories.Single(c => c.Key == "Files").RequiresRepo);
}
[Fact]
public async Task SessionSkillSelection_survives_a_save_and_reload_round_trip()
{
// The only real regression risk of the Settings-sidebar rework: Session Skills used to be
// two separate lists (a General-tab checkbox list + the Skills-tab management list) and is
// now one merged list. Setting the selection, saving, and reloading into a *fresh* VM must
// come back with the identical selection.
var installed = new List<SessionSkillDto>
{
new("alpha", "", "url-alpha", "ref", DateTimeOffset.UtcNow),
new("beta", "", "url-beta", "ref", DateTimeOffset.UtcNow),
};
var worker = new FakeWorker { AppToReturn = DtoWith(80, 90), InstalledSkills = installed };
var vm = MakeVm(worker);
await vm.LoadAsync();
vm.SessionSkills.Skills.Single(s => s.Name == "alpha").IsSelected = true;
vm.SessionSkills.Skills.Single(s => s.Name == "beta").IsSelected = false;
await vm.SaveCommand.ExecuteAsync(null);
Assert.Equal(new List<string> { "alpha" }, worker.Saved!.SessionSkills);
// Reload into a brand-new VM instance, as if the modal were closed and reopened.
var worker2 = new FakeWorker { AppToReturn = worker.Saved, InstalledSkills = installed };
var vm2 = MakeVm(worker2);
await vm2.LoadAsync();
Assert.True(vm2.SessionSkills.Skills.Single(s => s.Name == "alpha").IsSelected);
Assert.False(vm2.SessionSkills.Skills.Single(s => s.Name == "beta").IsSelected);
Assert.Equal(new List<string> { "alpha" }, vm2.SessionSkills.SelectedSkillNames());
}
}