OpenListSettingsAsync/CreateListAsync used to await LoadAsync (name/agent config/list_config x2/ticket settings/ticket projects, all serial) before showing the window -- an unreachable ticket system alone could add a 10s HTTP timeout to every open. LoadAsync now runs via BeginLoad (fire-and-forget, mirrors UsageMonitorModalViewModel), the modal shows immediately and fields fill in behind it. GetListConfigAsync no longer runs twice per open -- AgentConfigEditorViewModel exposes the row it already fetched (LastLoadedListConfig) instead of the caller re-fetching it. Ticket-project loading is split into its own fire-and-forget step so a slow/unreachable ticket system can't block the rest of the fields, and its tri-state Save logic now also treats "never finished loading" like "not available" -- leaving the stored TicketProjectId link untouched instead of overwriting it with 0. Save/Delete are gated (CanExecute) on the core load being done, so a save can't land before the fields it writes are populated. Not measured with OperationTiming against a live worker in this session (no running app instance available) -- verified instead via 4 new unit tests that assert GetListConfigAsync fires exactly once and that a hung/failing ticket-projects call neither blocks LoadAsync's other fields nor corrupts the saved TicketProjectId. Before/after OperationTiming numbers and a visual pass are still open follow-ups.
371 lines
16 KiB
C#
371 lines
16 KiB
C#
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;
|
|
using ClaudeDo.Ui.Localization;
|
|
using ClaudeDo.Ui.Services;
|
|
|
|
namespace ClaudeDo.Ui.ViewModels.Agent;
|
|
|
|
public enum AgentConfigScope { List, Task }
|
|
|
|
/// <summary>
|
|
/// One agent-config editor (Model / MaxTurns / SystemPrompt / AgentFile with inherited
|
|
/// badges + reset) shared by the List Settings modal and the per-task gear flyout.
|
|
/// Scope picks the inheritance depth (List: list→global; Task: task→list→global) and the
|
|
/// persistence (List: explicit <see cref="SaveAsync"/>; Task: debounced auto-save).
|
|
/// </summary>
|
|
public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposable
|
|
{
|
|
private readonly IWorkerClient _worker;
|
|
private readonly AgentConfigScope _scope;
|
|
private readonly EventHandler _langChangedHandler;
|
|
|
|
/// scope==List ⇒ the list id; scope==Task ⇒ the task id. Null ⇒ no save target.
|
|
internal string? TargetId { get; set; }
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(IsEnabled))]
|
|
private bool _isRunning;
|
|
|
|
// Task scope gates the editor while the run is live; List scope is always enabled.
|
|
public bool IsEnabled => !IsRunning;
|
|
|
|
[ObservableProperty] private string? _model;
|
|
[ObservableProperty] private decimal? _maxTurns;
|
|
[ObservableProperty] private string _systemPrompt = "";
|
|
[ObservableProperty] private AgentInfo? _selectedAgent;
|
|
// Null = inherit (list → global). Restricted to PermissionModeRegistry.Modes by the ComboBox.
|
|
[ObservableProperty] private string? _permissionMode;
|
|
|
|
[ObservableProperty] private string _modelBadge = "";
|
|
[ObservableProperty] private string _modelInheritedHint = "";
|
|
[ObservableProperty] private string _turnsBadge = "";
|
|
[ObservableProperty] private string _turnsInheritedHint = "";
|
|
[ObservableProperty] private string _turnsCeilingHint = "";
|
|
[ObservableProperty] private string _agentBadge = "";
|
|
[ObservableProperty] private string _permissionBadge = "";
|
|
[ObservableProperty] private string _permissionInheritedHint = "";
|
|
[ObservableProperty] private string _effectiveSystemPromptHint = "";
|
|
|
|
private string _globalModel = ModelRegistry.DefaultAlias;
|
|
// The global max-turns default is per-model (Settings -> General), so it moves with whichever
|
|
// model actually ends up in effect here.
|
|
private IReadOnlyList<ModelPreset> _presets = ModelPresets.Defaults;
|
|
private int _maxTurnsCeiling = 80;
|
|
private string EffectiveModel => Model ?? _listModel ?? _globalModel;
|
|
private int GlobalMaxTurns => ModelPresets.For(_presets, EffectiveModel).MaxTurns;
|
|
private string _globalPermissionMode = PermissionModeRegistry.DefaultMode;
|
|
private string? _listModel; // Task scope only
|
|
private int? _listMaxTurns; // Task scope only
|
|
private string? _listAgentName; // Task scope only
|
|
private string? _listPermissionMode; // Task scope only
|
|
|
|
private bool _suppressSave;
|
|
private CancellationTokenSource? _saveCts;
|
|
|
|
public int EffectiveMaxTurns =>
|
|
MaxTurns is decimal t ? (int)t : (_listMaxTurns ?? GlobalMaxTurns);
|
|
|
|
public ObservableCollection<string> ModelOptions { get; } = new(ModelRegistry.Aliases);
|
|
public IReadOnlyList<string> PermissionModeOptions { get; } = PermissionModeRegistry.Modes;
|
|
public ObservableCollection<AgentInfo> Agents { get; } = new();
|
|
public ObservableCollection<SelectableSkillViewModel> SessionSkills { get; } = new();
|
|
|
|
public AgentConfigEditorViewModel(IWorkerClient worker, AgentConfigScope scope)
|
|
{
|
|
_worker = worker;
|
|
_scope = scope;
|
|
_langChangedHandler = (_, _) => RecomputeBadges();
|
|
// Only the long-lived Task editor needs live re-badging; the List editor is a
|
|
// short-lived modal recreated with the current language on each open.
|
|
if (scope == AgentConfigScope.Task)
|
|
Loc.LanguageChanged += _langChangedHandler;
|
|
|
|
SessionSkills.CollectionChanged += OnSessionSkillsCollectionChanged;
|
|
}
|
|
|
|
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();
|
|
// A different model means a different global turn default.
|
|
RecomputeTurnsBadge();
|
|
OnPropertyChanged(nameof(EffectiveMaxTurns));
|
|
QueueSave();
|
|
}
|
|
|
|
partial void OnMaxTurnsChanged(decimal? value)
|
|
{
|
|
RecomputeTurnsBadge();
|
|
OnPropertyChanged(nameof(EffectiveMaxTurns));
|
|
QueueSave();
|
|
}
|
|
|
|
partial void OnSystemPromptChanged(string value) => QueueSave();
|
|
partial void OnSelectedAgentChanged(AgentInfo? value) { RecomputeAgentBadge(); QueueSave(); }
|
|
partial void OnPermissionModeChanged(string? value) { RecomputePermissionBadge(); QueueSave(); }
|
|
|
|
private void RecomputeBadges()
|
|
{
|
|
RecomputeModelBadge();
|
|
RecomputeTurnsBadge();
|
|
RecomputeAgentBadge();
|
|
RecomputePermissionBadge();
|
|
}
|
|
|
|
private void RecomputeModelBadge()
|
|
{
|
|
var own = string.IsNullOrWhiteSpace(Model) ? null : Model;
|
|
var (value, source) = _scope == AgentConfigScope.Task
|
|
? InheritanceResolver.Resolve(own, _listModel, _globalModel)
|
|
: InheritanceResolver.ResolveList(own, _globalModel);
|
|
ModelInheritedHint = value;
|
|
ModelBadge = BadgeFor(source, own is not null);
|
|
}
|
|
|
|
private void RecomputeTurnsBadge()
|
|
{
|
|
var own = MaxTurns?.ToString();
|
|
var (value, source) = _scope == AgentConfigScope.Task
|
|
? InheritanceResolver.Resolve(own, _listMaxTurns?.ToString(), GlobalMaxTurns.ToString())
|
|
: InheritanceResolver.ResolveList(own, GlobalMaxTurns.ToString());
|
|
TurnsInheritedHint = value;
|
|
TurnsBadge = BadgeFor(source, MaxTurns is not null);
|
|
TurnsCeilingHint = MaxTurns is decimal t && (int)t > _maxTurnsCeiling
|
|
? Loc.T("settings.turnsCeilingHint", _maxTurnsCeiling)
|
|
: "";
|
|
}
|
|
|
|
private void RecomputeAgentBadge()
|
|
{
|
|
var agentSet = SelectedAgent is not null && !string.IsNullOrWhiteSpace(SelectedAgent.Path);
|
|
var own = agentSet ? SelectedAgent!.Path : null;
|
|
var (_, source) = _scope == AgentConfigScope.Task
|
|
? InheritanceResolver.Resolve(own, _listAgentName, null)
|
|
: InheritanceResolver.ResolveList(own, null);
|
|
AgentBadge = BadgeFor(source, agentSet);
|
|
}
|
|
|
|
private void RecomputePermissionBadge()
|
|
{
|
|
var own = string.IsNullOrWhiteSpace(PermissionMode) ? null : PermissionMode;
|
|
var (value, source) = _scope == AgentConfigScope.Task
|
|
? InheritanceResolver.Resolve(own, _listPermissionMode, _globalPermissionMode)
|
|
: InheritanceResolver.ResolveList(own, _globalPermissionMode);
|
|
PermissionInheritedHint = value;
|
|
PermissionBadge = BadgeFor(source, own is not null);
|
|
}
|
|
|
|
private static string BadgeFor(InheritSource source, bool isSet) => isSet
|
|
? Loc.T("settings.inherit.overrideBadge")
|
|
: source == InheritSource.List
|
|
? Loc.T("settings.inherit.inheritedFromList")
|
|
: Loc.T("settings.inherit.inheritedFromGlobal");
|
|
|
|
private void QueueSave()
|
|
{
|
|
// List scope persists on the modal Save button; only Task auto-saves.
|
|
if (_suppressSave || _scope != AgentConfigScope.Task || TargetId is null) return;
|
|
_saveCts?.Cancel();
|
|
_saveCts = new CancellationTokenSource();
|
|
_ = DebouncedSaveAsync(_saveCts.Token);
|
|
}
|
|
|
|
private async System.Threading.Tasks.Task DebouncedSaveAsync(CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
await System.Threading.Tasks.Task.Delay(300, ct);
|
|
if (TargetId is null) return;
|
|
await SaveAsync();
|
|
}
|
|
catch (OperationCanceledException) { }
|
|
catch { }
|
|
}
|
|
|
|
// verifyCommand, ticketProjectId and powerUseAllowed are List-only fields owned by
|
|
// ListSettingsModalViewModel (not this editor, which is also reused for Task scope); the caller
|
|
// passes them through so the single UpdateListConfig call carries the full desired row instead
|
|
// of clobbering it. powerUseAllowed stays null-by-default for the same reason ticketProjectId
|
|
// does: a caller that doesn't own it must not revoke the list's opt-in by omission.
|
|
public async System.Threading.Tasks.Task SaveAsync(
|
|
string? verifyCommand = null, int? ticketProjectId = null, bool? powerUseAllowed = null)
|
|
{
|
|
if (TargetId is null) return;
|
|
var model = string.IsNullOrWhiteSpace(Model) ? null : Model;
|
|
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();
|
|
var permission = string.IsNullOrWhiteSpace(PermissionMode) ? null : PermissionMode;
|
|
|
|
if (_scope == AgentConfigScope.Task)
|
|
await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills, permission));
|
|
else
|
|
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand, permission, ticketProjectId, powerUseAllowed));
|
|
}
|
|
|
|
private List<string>? SelectedSessionSkillNames()
|
|
{
|
|
var names = SessionSkills.Where(s => s.IsSelected).Select(s => s.Name).ToList();
|
|
return names.Count == 0 ? null : names;
|
|
}
|
|
|
|
// The list_config row fetched by the most recent LoadForListAsync — exposed so a caller that
|
|
// needs the same row (ListSettingsModalViewModel) can reuse it instead of fetching it again.
|
|
public ListConfigDto? LastLoadedListConfig { get; private set; }
|
|
|
|
public async System.Threading.Tasks.Task LoadForListAsync(string listId, CancellationToken ct = default)
|
|
{
|
|
_suppressSave = true;
|
|
try
|
|
{
|
|
TargetId = listId;
|
|
await ReloadAgentsAsync("(none)");
|
|
await LoadGlobalDefaultsAsync();
|
|
|
|
var cfg = await _worker.GetListConfigAsync(listId);
|
|
LastLoadedListConfig = cfg;
|
|
ApplyConfig(cfg?.Model, cfg?.MaxTurns, cfg?.SystemPrompt, cfg?.AgentPath, cfg?.PermissionMode);
|
|
await ReloadSessionSkillsAsync(cfg?.SessionSkills);
|
|
|
|
_listModel = null; _listMaxTurns = null; _listAgentName = null; _listPermissionMode = null;
|
|
EffectiveSystemPromptHint = "";
|
|
RecomputeBadges();
|
|
OnPropertyChanged(nameof(EffectiveMaxTurns));
|
|
}
|
|
finally { _suppressSave = false; }
|
|
}
|
|
|
|
public async System.Threading.Tasks.Task LoadForTaskAsync(TaskEntity entity, CancellationToken ct = default)
|
|
{
|
|
_suppressSave = true;
|
|
try
|
|
{
|
|
TargetId = entity.Id;
|
|
await ReloadAgentsAsync("(inherited)");
|
|
ApplyConfig(entity.Model, entity.MaxTurns, entity.SystemPrompt, entity.AgentPath, entity.PermissionMode);
|
|
await ReloadSessionSkillsAsync(ParseSessionSkills(entity.SessionSkills));
|
|
|
|
var listCfg = await _worker.GetListConfigAsync(entity.ListId);
|
|
await LoadGlobalDefaultsAsync();
|
|
_listModel = listCfg?.Model;
|
|
_listMaxTurns = listCfg?.MaxTurns;
|
|
_listPermissionMode = listCfg?.PermissionMode;
|
|
_listAgentName = string.IsNullOrWhiteSpace(listCfg?.AgentPath)
|
|
? null : System.IO.Path.GetFileName(listCfg!.AgentPath!);
|
|
EffectiveSystemPromptHint = string.IsNullOrWhiteSpace(listCfg?.SystemPrompt)
|
|
? "" : listCfg!.SystemPrompt!;
|
|
|
|
RecomputeBadges();
|
|
OnPropertyChanged(nameof(EffectiveMaxTurns));
|
|
}
|
|
finally { _suppressSave = false; }
|
|
}
|
|
|
|
public void Clear()
|
|
{
|
|
_suppressSave = true;
|
|
try
|
|
{
|
|
Model = null;
|
|
MaxTurns = null;
|
|
SystemPrompt = "";
|
|
SelectedAgent = null;
|
|
PermissionMode = 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();
|
|
Agents.Add(new AgentInfo(placeholderName, "", ""));
|
|
foreach (var a in await _worker.GetAgentsAsync()) Agents.Add(a);
|
|
}
|
|
|
|
private async System.Threading.Tasks.Task LoadGlobalDefaultsAsync()
|
|
{
|
|
var app = await _worker.GetAppSettingsAsync();
|
|
_globalModel = app?.DefaultModel ?? ModelRegistry.DefaultAlias;
|
|
_presets = app?.ModelPresets is { Count: > 0 } rows
|
|
? rows.Select(r => new ModelPreset(r.Model, r.Effort, r.MaxTurns)).ToList()
|
|
: ModelPresets.Defaults;
|
|
_maxTurnsCeiling = app?.MaxTurnsCeiling ?? 80;
|
|
_globalPermissionMode = app?.DefaultPermissionMode ?? PermissionModeRegistry.DefaultMode;
|
|
}
|
|
|
|
private void ApplyConfig(string? model, int? maxTurns, string? systemPrompt, string? agentPath,
|
|
string? permissionMode)
|
|
{
|
|
Model = string.IsNullOrWhiteSpace(model) ? null : model!;
|
|
MaxTurns = maxTurns is int mt ? mt : (decimal?)null;
|
|
SystemPrompt = systemPrompt ?? "";
|
|
PermissionMode = string.IsNullOrWhiteSpace(permissionMode) ? null : permissionMode!;
|
|
SelectedAgent = string.IsNullOrWhiteSpace(agentPath)
|
|
? Agents[0]
|
|
: (Agents.FirstOrDefault(a => a.Path == agentPath) ?? Agents[0]);
|
|
}
|
|
|
|
[RelayCommand] private void ResetModel() => Model = null;
|
|
[RelayCommand] private void ResetTurns() => MaxTurns = null;
|
|
[RelayCommand] private void ResetAgent() => SelectedAgent = Agents.Count > 0 ? Agents[0] : null;
|
|
[RelayCommand] private void ResetPermissionMode() => PermissionMode = null;
|
|
|
|
[RelayCommand]
|
|
private void ResetAll()
|
|
{
|
|
Model = null;
|
|
MaxTurns = null;
|
|
SystemPrompt = "";
|
|
SelectedAgent = Agents.Count > 0 ? Agents[0] : null;
|
|
PermissionMode = null;
|
|
foreach (var s in SessionSkills) s.IsSelected = false;
|
|
}
|
|
}
|