Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ the long-running model soak and local-model token measurements finish. Desktop's
the engine generation, so it moves 0.14.1 → 0.15.0.

### Added
- **Separate tags for Skills and MCP servers.** Use the new gear-icon **Tags** button on each row
to assign tags from a checkbox menu; assigned tags appear as colored chips beside that button.
The `+` button in Filters opens a tag-management dialog where new tags receive a chosen color.
Skill tags and MCP tags remain separate, and tags do not alter shared server configuration or
portable SKILL.md files.
- **Filtered bulk enable/disable.** The Filters group now includes an action that reads **Disable
all** whenever any matching item is active, or **Enable all** when every matching item is disabled.
It applies only to the current search, status, and tag result. The visible rows stay in place
while their switches update; MCP servers are saved and reloaded once for the full filtered set.
- **Responsive management filters.** The Filters group stays right-aligned beside the action buttons
when both fit. On a narrow window, it moves as a complete second row below those actions instead
of overlapping or partially wrapping.
- **An Unfinished Plan card appears when an agent has checkpointed work.** Resume continues at the
first unsettled step; Discard forgets the saved run. The card reflects current checkpoint state
rather than transcript history, so an obsolete Resume button cannot come back after restart.
Expand Down
67 changes: 67 additions & 0 deletions src/MandoCode.Desktop.Tests/ItemTagStoreTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using MandoCode.Desktop.Services;
using Xunit;

namespace MandoCode.Desktop.Tests;

public sealed class ItemTagStoreTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "mandocode-tags-" + Guid.NewGuid().ToString("N"));
private readonly ItemTagStore _store;

public ItemTagStoreTests()
{
Directory.CreateDirectory(_directory);
_store = new ItemTagStore(Path.Combine(_directory, "tags.json"));
}

[Fact]
public void Tags_are_separate_for_skills_and_mcps()
{
_store.SetItemTags(TagScope.Skills, "C:\\skills\\review", ["quality"]);
_store.SetItemTags(TagScope.Mcps, "database", ["production"]);

Assert.Equal(["quality"], _store.GetTags(TagScope.Skills).Select(tag => tag.Name));
Assert.Equal(["production"], _store.GetTags(TagScope.Mcps).Select(tag => tag.Name));
Assert.Equal(["quality"], _store.GetItemTags(TagScope.Skills, "C:\\skills\\review"));
Assert.DoesNotContain("quality", _store.GetItemTags(TagScope.Mcps, "database"));
}

[Fact]
public void Rename_moves_an_items_tags()
{
_store.SetItemTags(TagScope.Mcps, "old-name", ["local", "utility"]);

_store.RenameItem(TagScope.Mcps, "old-name", "new-name");

Assert.Empty(_store.GetItemTags(TagScope.Mcps, "old-name"));
Assert.Equal(["local", "utility"], _store.GetItemTags(TagScope.Mcps, "new-name"));
}

[Fact]
public void Deleting_a_tag_removes_it_from_all_assignments()
{
_store.SetItemTags(TagScope.Skills, "one", ["review", "shared"]);
_store.SetItemTags(TagScope.Skills, "two", ["shared"]);

_store.DeleteTag(TagScope.Skills, "shared");

Assert.Equal(["review"], _store.GetItemTags(TagScope.Skills, "one"));
Assert.Empty(_store.GetItemTags(TagScope.Skills, "two"));
Assert.Equal(["review"], _store.GetTags(TagScope.Skills).Select(tag => tag.Name));
}

[Fact]
public void Added_tag_keeps_its_selected_color()
{
_store.AddTag(TagScope.Skills, "release", "#A855F7");

var tag = Assert.Single(_store.GetTags(TagScope.Skills));
Assert.Equal("release", tag.Name);
Assert.Equal("#A855F7", tag.Color);
}

public void Dispose()
{
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
}
1 change: 1 addition & 0 deletions src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
<Compile Include="..\MandoCode.Desktop\Services\ConfigCloning.cs" Link="src\ConfigCloning.cs" />
<Compile Include="..\MandoCode.Desktop\Services\AgentNaming.cs" Link="src\AgentNaming.cs" />
<Compile Include="..\MandoCode.Desktop\Services\AgentCallsigns.cs" Link="src\AgentCallsigns.cs" />
<Compile Include="..\MandoCode.Desktop\Services\ItemTagStore.cs" Link="src\ItemTagStore.cs" />
<Compile Include="..\MandoCode.Desktop\Services\HistorySummarizer.cs" Link="src\HistorySummarizer.cs" />
<Compile Include="..\MandoCode.Desktop\ViewModels\RequestPreambleComposer.cs" Link="src\RequestPreambleComposer.cs" />
<Compile Include="..\MandoCode.Desktop\ViewModels\DeferredPlanCompletion.cs" Link="src\DeferredPlanCompletion.cs" />
Expand Down
86 changes: 81 additions & 5 deletions src/MandoCode.Desktop/MainWindow.Mcp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public sealed partial class MainWindow
// Full unfiltered set; the list shows what matches the search box (see ApplyMcpFilter).
private List<McpRow> _allMcpRows = new();
private bool _loadingMcp;
private string? _mcpTagFilter;

private async Task RefreshMcpListAsync()
{
Expand All @@ -44,15 +45,19 @@ private async Task RefreshMcpListAsync()

var green = (SolidColorBrush)Application.Current.Resources["MandoGreenBrush"];
var gold = (SolidColorBrush)Application.Current.Resources["MandoGoldBrush"];
var definitions = _itemTags.GetTags(TagScope.Mcps);
_allMcpRows = rows.Select(r => new McpRow
{
Name = r.Name,
Transport = r.Transport,
Status = r.Status,
StatusBrush = r.Connected ? green : gold,
Enabled = !r.Disabled,
Tags = _itemTags.GetItemTags(TagScope.Mcps, r.Name),
TagChips = TagChips(_itemTags.GetItemTags(TagScope.Mcps, r.Name), definitions),
}).ToList();

PopulateMcpTagFilter();
ApplyMcpFilter();
}

Expand All @@ -61,6 +66,21 @@ private void McpSearch_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChan

private string _mcpFilter = "all";

private void PopulateMcpTagFilter()
{
var choices = new List<TagFilterOption> { new() };
choices.AddRange(_itemTags.GetTags(TagScope.Mcps).Select(tag => new TagFilterOption { Label = tag.Name, Tag = tag.Name }));
McpTagFilter.ItemsSource = choices;
McpTagFilter.SelectedItem = choices.FirstOrDefault(choice =>
string.Equals(choice.Tag, _mcpTagFilter, StringComparison.OrdinalIgnoreCase)) ?? choices[0];
}

private void McpTagFilter_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
_mcpTagFilter = (McpTagFilter.SelectedItem as TagFilterOption)?.Tag;
ApplyMcpFilter();
}

private void McpFilter_Click(object sender, RoutedEventArgs e)
{
_mcpFilter = (string)((FrameworkElement)sender).Tag;
Expand Down Expand Up @@ -88,6 +108,8 @@ private void ApplyMcpFilter()
"failed" => filtered.Where(r => r.Status.StartsWith("failed", StringComparison.OrdinalIgnoreCase)),
_ => filtered,
};
if (!string.IsNullOrWhiteSpace(_mcpTagFilter))
filtered = filtered.Where(row => row.Tags.Contains(_mcpTagFilter, StringComparer.OrdinalIgnoreCase));
var shown = filtered.ToList();

var groups = new List<McpRowGroup>();
Expand All @@ -103,10 +125,12 @@ private void ApplyMcpFilter()

McpEditButton.IsEnabled = false;
McpRemoveButton.IsEnabled = false;
McpBulkToggleButton.IsEnabled = shown.Count > 0;
McpBulkToggleButton.Content = shown.Any(row => row.Enabled) ? "Disable all" : "Enable all";

var total = _allMcpRows.Count;
var enabledTotal = _allMcpRows.Count(r => r.Enabled);
var active = q.Length > 0 || _mcpFilter != "all";
var active = q.Length > 0 || _mcpFilter != "all" || !string.IsNullOrWhiteSpace(_mcpTagFilter);
if (total == 0)
McpPageStatus.Text = "No MCP servers configured yet — “Add MCP Server” to connect one.";
else if (active)
Expand All @@ -115,6 +139,49 @@ private void ApplyMcpFilter()
McpPageStatus.Text = $"{total} server{(total == 1 ? "" : "s")}, {enabledTotal} enabled";
}

private async void McpManageTags_Click(object sender, RoutedEventArgs e)
{
await ShowTagManagerAsync(TagScope.Mcps, "MCP tags");
await RefreshMcpListAsync();
}

private async void McpBulkToggle_Click(object sender, RoutedEventArgs e)
{
var targets = FilteredMcpRows();
var enable = !targets.Any(row => row.Enabled);
McpPageStatus.Text = enable ? "Enabling filtered servers…" : "Disabling filtered servers…";
var result = await _controller.SetMcpServersEnabledAsync(targets.Select(row => row.Name), enable);
if (!result.Ok)
{
McpPageStatus.Text = result.Message;
return;
}

// Keep the current filtered layout stable. The next explicit refresh re-groups rows.
foreach (var row in targets) row.Enabled = enable;
McpBulkToggleButton.Content = enable ? "Disable all" : "Enable all";
McpPageStatus.Text = result.Message;
}

private List<McpRow> FilteredMcpRows()
{
var q = McpSearchBox.Text?.Trim() ?? "";
IEnumerable<McpRow> rows = _allMcpRows;
if (q.Length > 0)
rows = rows.Where(row => row.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
row.Transport.Contains(q, StringComparison.OrdinalIgnoreCase));
rows = _mcpFilter switch
{
"enabled" => rows.Where(row => row.Enabled),
"disabled" => rows.Where(row => !row.Enabled),
"failed" => rows.Where(row => row.Status.StartsWith("failed", StringComparison.OrdinalIgnoreCase)),
_ => rows,
};
return string.IsNullOrWhiteSpace(_mcpTagFilter)
? rows.ToList()
: rows.Where(row => row.Tags.Contains(_mcpTagFilter, StringComparer.OrdinalIgnoreCase)).ToList();
}

/// <summary>Per-server on/off. Flips the shared config's Disabled flag and saves, which restarts
/// the servers and re-registers tools on every agent (SaveMcpServerAsync → coordinator reload).</summary>
private async void McpEnabled_Toggled(object sender, RoutedEventArgs e)
Expand All @@ -125,12 +192,20 @@ private async void McpEnabled_Toggled(object sender, RoutedEventArgs e)
if (sw.IsOn == row.Enabled) return;

// Edit the canonical defaults entry (what SaveMcpServerAsync persists), flip Disabled, save.
if (!_configs.Defaults.McpServers.TryGetValue(row.Name, out var server)) return;
server.Disabled = !sw.IsOn;
if (!_configs.Defaults.McpServers.ContainsKey(row.Name)) return;

McpPageStatus.Text = sw.IsOn ? $"Enabling “{row.Name}”…" : $"Disabling “{row.Name}”…";
await Task.Run(() => _controller.SaveMcpServerAsync(row.Name, row.Name, server));
await RefreshMcpListAsync();
var result = await _controller.SetMcpServersEnabledAsync([row.Name], sw.IsOn);
if (!result.Ok)
{
sw.IsOn = row.Enabled;
McpPageStatus.Text = result.Message;
return;
}

row.Enabled = sw.IsOn;
McpBulkToggleButton.Content = sw.IsOn ? "Disable all" : "Enable all";
McpPageStatus.Text = result.Message;
}

/// <summary>Runs a slash command through the normal pipeline (transcript echo, wizard
Expand Down Expand Up @@ -382,6 +457,7 @@ private async void McpEditorSave_Click(object sender, RoutedEventArgs e)

var originalName = _mcpEditOriginalName;
var (_, message) = await Task.Run(() => _controller.SaveMcpServerAsync(originalName, name, server));
if (!string.IsNullOrWhiteSpace(originalName)) _itemTags.RenameItem(TagScope.Mcps, originalName, name);
McpPageStatus.Text = message;
await RefreshMcpListAsync();
McpPageStatus.Text = message;
Expand Down
74 changes: 71 additions & 3 deletions src/MandoCode.Desktop/MainWindow.Skills.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,23 @@ public sealed partial class MainWindow

// Full unfiltered set; the ListView shows whatever matches the search box (see ApplySkillFilter).
private List<SkillRow> _allSkillRows = new();
private string? _skillTagFilter;

private void RefreshSkillsList()
{
var definitions = _itemTags.GetTags(TagScope.Skills);
_allSkillRows = _skillCoordinator.ListGlobalSkills().Select(s => new SkillRow
{
Name = s.Name,
Description = s.Description,
Body = s.Body,
FolderPath = s.FolderPath,
Enabled = s.Enabled,
Tags = _itemTags.GetItemTags(TagScope.Skills, s.FolderPath),
TagChips = TagChips(_itemTags.GetItemTags(TagScope.Skills, s.FolderPath), definitions),
}).ToList();

PopulateSkillTagFilter();
ApplySkillFilter();
}

Expand All @@ -50,6 +55,21 @@ private void SkillSearch_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextCh

private string _skillFilter = "all";

private void PopulateSkillTagFilter()
{
var choices = new List<TagFilterOption> { new() };
choices.AddRange(_itemTags.GetTags(TagScope.Skills).Select(tag => new TagFilterOption { Label = tag.Name, Tag = tag.Name }));
SkillTagFilter.ItemsSource = choices;
SkillTagFilter.SelectedItem = choices.FirstOrDefault(choice =>
string.Equals(choice.Tag, _skillTagFilter, StringComparison.OrdinalIgnoreCase)) ?? choices[0];
}

private void SkillTagFilter_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
_skillTagFilter = (SkillTagFilter.SelectedItem as TagFilterOption)?.Tag;
ApplySkillFilter();
}

private void SkillFilter_Click(object sender, RoutedEventArgs e)
{
_skillFilter = (string)((FrameworkElement)sender).Tag;
Expand Down Expand Up @@ -78,6 +98,8 @@ private void ApplySkillFilter()
"large" => filtered.Where(r => r.IsLarge),
_ => filtered,
};
if (!string.IsNullOrWhiteSpace(_skillTagFilter))
filtered = filtered.Where(row => row.Tags.Contains(_skillTagFilter, StringComparer.OrdinalIgnoreCase));
var shown = filtered.ToList();

// Group by state — Enabled first, Disabled below; empty sections omitted.
Expand All @@ -93,10 +115,12 @@ private void ApplySkillFilter()
// Resetting ItemsSource clears the selection, so the selection-scoped buttons go with it.
SkillEditButton.IsEnabled = false;
SkillDeleteButton.IsEnabled = false;
SkillBulkToggleButton.IsEnabled = shown.Count > 0;
SkillBulkToggleButton.Content = shown.Any(row => row.Enabled) ? "Disable all" : "Enable all";

var total = _allSkillRows.Count;
var enabledTotal = _allSkillRows.Count(r => r.Enabled);
var active = q.Length > 0 || _skillFilter != "all";
var active = q.Length > 0 || _skillFilter != "all" || !string.IsNullOrWhiteSpace(_skillTagFilter);
if (total == 0)
SkillsPageStatus.Text = $"No global skills yet — “New Skill” or “Install from…” to add one. ({_skillCoordinator.UserSkillsDirectory})";
else if (active)
Expand All @@ -105,6 +129,45 @@ private void ApplySkillFilter()
SkillsPageStatus.Text = $"{total} skill{(total == 1 ? "" : "s")}, {enabledTotal} enabled · {_skillCoordinator.UserSkillsDirectory}";
}

private async void SkillManageTags_Click(object sender, RoutedEventArgs e)
{
await ShowTagManagerAsync(TagScope.Skills, "Skill tags");
RefreshSkillsList();
}

private async void SkillBulkToggle_Click(object sender, RoutedEventArgs e)
{
var targets = FilteredSkillRows();
var enable = !targets.Any(row => row.Enabled);
foreach (var row in targets)
_skillCoordinator.SetEnabled(row.FolderPath, enable);

await _skillCoordinator.ReloadAllAsync();
// Keep the current filtered layout stable. The next explicit refresh re-groups rows.
foreach (var row in targets) row.Enabled = enable;
SkillBulkToggleButton.Content = enable ? "Disable all" : "Enable all";
SkillsPageStatus.Text = enable ? $"Enabled {targets.Count} filtered skill(s)." : $"Disabled {targets.Count} filtered skill(s).";
}

private List<SkillRow> FilteredSkillRows()
{
var q = SkillSearchBox.Text?.Trim() ?? "";
IEnumerable<SkillRow> rows = _allSkillRows;
if (q.Length > 0)
rows = rows.Where(row => row.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
row.Description.Contains(q, StringComparison.OrdinalIgnoreCase));
rows = _skillFilter switch
{
"enabled" => rows.Where(row => row.Enabled),
"disabled" => rows.Where(row => !row.Enabled),
"large" => rows.Where(row => row.IsLarge),
_ => rows,
};
return string.IsNullOrWhiteSpace(_skillTagFilter)
? rows.ToList()
: rows.Where(row => row.Tags.Contains(_skillTagFilter, StringComparer.OrdinalIgnoreCase)).ToList();
}

/// <summary>Reload every agent's skill set + prompt, then re-render the list and report.</summary>
private async Task ApplySkillChangeAsync(string status)
{
Expand Down Expand Up @@ -138,7 +201,10 @@ private async void SkillEnabled_Toggled(object sender, RoutedEventArgs e)
try
{
_skillCoordinator.SetEnabled(row.FolderPath, sw.IsOn);
await ApplySkillChangeAsync(sw.IsOn ? $"Enabled “{row.Name}”." : $"Disabled “{row.Name}”.");
await _skillCoordinator.ReloadAllAsync();
row.Enabled = sw.IsOn;
SkillBulkToggleButton.Content = sw.IsOn ? "Disable all" : "Enable all";
SkillsPageStatus.Text = sw.IsOn ? $"Enabled “{row.Name}”." : $"Disabled “{row.Name}”.";
}
catch (Exception ex)
{
Expand Down Expand Up @@ -339,7 +405,9 @@ private async void SkillEditorSave_Click(object sender, RoutedEventArgs e)

try
{
_skillCoordinator.SaveSkill(_editingSkillFolder, name, Sk_Description.Text, Sk_Body.Text);
var folder = _skillCoordinator.SaveSkill(_editingSkillFolder, name, Sk_Description.Text, Sk_Body.Text);
if (!string.IsNullOrWhiteSpace(_editingSkillFolder))
_itemTags.RenameItem(TagScope.Skills, _editingSkillFolder, folder);
SkillEditorOverlay.Visibility = Visibility.Collapsed;
await ApplySkillChangeAsync($"Saved “{name}”.");
}
Expand Down
Loading
Loading