Skip to content

Commit a6f9729

Browse files
committed
Add Copilot prompt panel to TODO detail with tests
Implement Copilot Status/Plan/Implementation prompt buttons and output panel in the TODO detail page, with robust error handling and inline feedback. Prompt errors are shown without collapsing the detail panel. Add Bunit tests for prompt UI and error handling. Refactor VSIX packaging to SDK-style, update UI dispatcher abstractions for WPF/Avalonia/test, and modernize ViewModel and API adapters. Update docs, .gitignore, and bump NuGet versions.
1 parent 2523f41 commit a6f9729

26 files changed

Lines changed: 1279 additions & 307 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,5 @@ AGENTS-README-FIRST.yaml
3434
scripts/__pycache__/
3535
testResults.xml
3636
.vscode/mcp.json
37+
/logs/web-ui-startup
38+
/TestResults/run-all-tests

build/Build.UtilityTargets.cs

Lines changed: 115 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,119 @@ private string ResolveDevenvPath()
102102
throw new FileNotFoundException("Could not locate devenv.exe. Set DEVENV_PATH or install Visual Studio.");
103103
}
104104

105+
private string GetVsixProjectDirectory()
106+
{
107+
return Path.Combine(RepoRootPath, "src", "McpServer.VsExtension.McpTodo.Vsix");
108+
}
109+
110+
private string GetVsixProjectPath()
111+
{
112+
return Path.Combine(GetVsixProjectDirectory(), "McpServer.VsExtension.McpTodo.Vsix.csproj");
113+
}
114+
115+
private string ResolveVsixBuildRelativePath()
116+
{
117+
var projectDocument = XDocument.Load(GetVsixProjectPath());
118+
var targetFramework = projectDocument
119+
.Descendants()
120+
.FirstOrDefault(element => element.Name.LocalName == "TargetFramework")
121+
?.Value
122+
.Trim();
123+
124+
if (string.IsNullOrWhiteSpace(targetFramework))
125+
{
126+
var targetFrameworks = projectDocument
127+
.Descendants()
128+
.FirstOrDefault(element => element.Name.LocalName == "TargetFrameworks")
129+
?.Value;
130+
targetFramework = targetFrameworks?
131+
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
132+
.FirstOrDefault();
133+
}
134+
135+
if (string.IsNullOrWhiteSpace(targetFramework))
136+
{
137+
throw new InvalidOperationException($"Could not determine TargetFramework from {GetVsixProjectPath()}");
138+
}
139+
140+
var runtimeIdentifier = projectDocument
141+
.Descendants()
142+
.FirstOrDefault(element => element.Name.LocalName == "RuntimeIdentifier")
143+
?.Value
144+
.Trim();
145+
146+
return string.IsNullOrWhiteSpace(runtimeIdentifier)
147+
? targetFramework
148+
: Path.Combine(targetFramework, runtimeIdentifier);
149+
}
150+
151+
private string ResolveVsixOutputDirectory()
152+
{
153+
return Path.Combine(GetVsixProjectDirectory(), "bin", Configuration, ResolveVsixBuildRelativePath());
154+
}
155+
156+
private string ResolveVsixObjectDirectory()
157+
{
158+
return Path.Combine(GetVsixProjectDirectory(), "obj", Configuration, ResolveVsixBuildRelativePath());
159+
}
160+
161+
private void WriteVsixPkgDefFile(string pkgDefPath, string dllFileName)
162+
{
163+
// CreatePkgDef.exe cannot reflect the VSIX assembly after the project moved to net9.0-windows,
164+
// so keep emitting the same registration shape the extension already uses in Visual Studio.
165+
const string packageGuid = "{e8f0a1b2-3c4d-4e5f-8a9b-0c1d2e3f4a5b}";
166+
const string packageClass = "McpServer.VsExtension.McpTodo.McpServerMcpTodoPackage";
167+
const string assemblyIdentity = "McpServer.VsExtension.McpTodo, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null";
168+
const string solutionExistsContextGuid = "{f1536ef8-92ec-443c-9ed7-fdadf150da82}";
169+
const string toolWindowGuid = "{a1b2c3d4-e5f6-7890-abcd-ef1234567890}";
170+
const string toolWindowClass = "McpServer.VsExtension.McpTodo.McpServerMcpTodoToolWindowPane";
171+
const string toolWindowHostGuid = "3ae79031-e1bc-11d0-8f78-00a0c9110057";
172+
173+
var lines = new[]
174+
{
175+
$"[$RootKey$\\Packages\\{packageGuid}]",
176+
"@=\"McpServerMcpTodoPackage\"",
177+
"\"InprocServer32\"=\"$WinDir$\\SYSTEM32\\MSCOREE.DLL\"",
178+
$"\"Class\"=\"{packageClass}\"",
179+
$"\"Assembly\"=\"{assemblyIdentity}\"",
180+
"\"AllowsBackgroundLoad\"=dword:00000001",
181+
$"[$RootKey$\\AutoLoadPackages\\{solutionExistsContextGuid}]",
182+
$"\"{packageGuid}\"=dword:00000002",
183+
"[$RootKey$\\Menus]",
184+
$"\"{packageGuid}\"=\", Menus.ctmenu, 1\"",
185+
$"[$RootKey$\\ToolWindows\\{toolWindowGuid}]",
186+
$"@=\"{packageGuid}\"",
187+
$"\"Name\"=\"{toolWindowClass}\"",
188+
"\"Style\"=\"Tabbed\"",
189+
$"\"Window\"=\"{toolWindowHostGuid}\"",
190+
string.Empty
191+
};
192+
193+
EnsureDirectoryExists(Path.GetDirectoryName(pkgDefPath)!);
194+
File.WriteAllText(pkgDefPath, string.Join(Environment.NewLine, lines), Encoding.Unicode);
195+
196+
var injectCodeBasePath = Path.Combine(GetVsixProjectDirectory(), "InjectCodeBase.ps1");
197+
InvokeProcess(
198+
"pwsh",
199+
new List<string>
200+
{
201+
"-NoProfile",
202+
"-File",
203+
injectCodeBasePath,
204+
"-PkgdefPath",
205+
pkgDefPath,
206+
"-DllName",
207+
dllFileName
208+
},
209+
RepoRootPath,
210+
true);
211+
}
212+
105213
private void RunPackageVsixTarget()
106214
{
107-
var extensionDirectory = Path.Combine(RepoRootPath, "src", "McpServer.VsExtension.McpTodo.Vsix");
108-
var outputDirectory = Path.Combine(extensionDirectory, "bin", Configuration, "net472", "win");
109-
var objectDirectory = Path.Combine(extensionDirectory, "obj", Configuration, "net472", "win");
215+
var extensionDirectory = GetVsixProjectDirectory();
216+
var outputDirectory = ResolveVsixOutputDirectory();
217+
var objectDirectory = ResolveVsixObjectDirectory();
110218
var stagingDirectory = Path.Combine(objectDirectory, "vsixstaging");
111219
var vsixPath = Path.Combine(outputDirectory, "McpServer.VsExtension.McpTodo.vsix");
112220
var dllPath = Path.Combine(outputDirectory, "McpServer.VsExtension.McpTodo.dll");
@@ -123,29 +231,8 @@ private void RunPackageVsixTarget()
123231
return;
124232
}
125233

126-
var nugetRoot = ResolveNuGetPackageRoot();
127-
var vssdkPackageRoot = Path.Combine(nugetRoot, "microsoft.vssdk.buildtools");
128-
if (!Directory.Exists(vssdkPackageRoot))
129-
{
130-
throw new DirectoryNotFoundException($"VSSDK build tools package directory not found at {vssdkPackageRoot}");
131-
}
132-
133-
var vssdkVersionDirectory = Directory.GetDirectories(vssdkPackageRoot)
134-
.OrderByDescending(path => path, StringComparer.OrdinalIgnoreCase)
135-
.FirstOrDefault();
136-
if (string.IsNullOrWhiteSpace(vssdkVersionDirectory))
137-
{
138-
throw new DirectoryNotFoundException($"No VSSDK version directories found at {vssdkPackageRoot}");
139-
}
140-
141-
var createPkgDefPath = Path.Combine(vssdkVersionDirectory, "tools", "vssdk", "bin", "CreatePkgDef.exe");
142-
if (!File.Exists(createPkgDefPath))
143-
{
144-
throw new FileNotFoundException($"CreatePkgDef not found at {createPkgDefPath}");
145-
}
146-
147234
EnsureDirectoryExists(objectDirectory);
148-
InvokeProcess(createPkgDefPath, new List<string> { $"/out={pkgDefPath}", dllPath }, RepoRootPath, true);
235+
WriteVsixPkgDefFile(pkgDefPath, Path.GetFileName(dllPath));
149236

150237
ClearDirectory(stagingDirectory);
151238
EnsureDirectoryExists(stagingDirectory);
@@ -249,7 +336,7 @@ private void RunPackageVsixTarget()
249336

250337
private void RunBuildAndInstallVsixTarget()
251338
{
252-
var projectPath = Path.Combine(RepoRootPath, "src", "McpServer.VsExtension.McpTodo.Vsix", "McpServer.VsExtension.McpTodo.Vsix.csproj");
339+
var projectPath = GetVsixProjectPath();
253340
if (!ShouldExecuteAction($"Build VSIX project {projectPath}"))
254341
{
255342
return;
@@ -258,7 +345,7 @@ private void RunBuildAndInstallVsixTarget()
258345
InvokeDotNet(new List<string> { "build", projectPath, "-c", Configuration }, RepoRootPath);
259346
RunPackageVsixTarget();
260347

261-
var vsixPath = Path.Combine(RepoRootPath, "src", "McpServer.VsExtension.McpTodo.Vsix", "bin", Configuration, "net472", "win", "McpServer.VsExtension.McpTodo.vsix");
348+
var vsixPath = Path.Combine(ResolveVsixOutputDirectory(), "McpServer.VsExtension.McpTodo.vsix");
262349
if (!SkipInstall)
263350
{
264351
if (!ShouldExecuteAction($"Launch VSIX installer for {vsixPath}"))
@@ -402,7 +489,7 @@ void RemoveExistingExtensionDirectories(string baseDirectory)
402489
private void RunDeployMcpTodoExtensionTarget()
403490
{
404491
var targetInstallDirectory = ResolveVsixInstallDirectory();
405-
var sourceDirectory = Path.Combine(RepoRootPath, "src", "McpServer.VsExtension.McpTodo.Vsix", "bin", Configuration, "net472", "win");
492+
var sourceDirectory = ResolveVsixOutputDirectory();
406493
if (!Directory.Exists(sourceDirectory))
407494
{
408495
throw new DirectoryNotFoundException($"VSIX output directory not found: {sourceDirectory}");

docs/todo.yaml

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -905,14 +905,38 @@ mvp-mcp:
905905
high-priority:
906906
- id: DEPLOY-ALLSCRIPT-001
907907
title: A script to deploy all targets
908-
done: false
908+
note: Closed on 2026-03-16 after auditing the current repository implementation against the MCP TODO scope. All six requested targets are implemented in the deploy-all flow, automated coverage exists, and non-destructive validation passed in this session. The AndroidEmulator target was skipped in the WhatIf smoke run only because no emulator was attached, which is an environment condition rather than an implementation gap.
909+
done: true
910+
completed: 2026-03-16T02:38:19.6106193Z
909911
description:
910-
- '- Director as Global Dotnet Tool'
911-
- '- Web-UI as Global Dotnet Tool'
912-
- '- Android to attached Phone'
913-
- '- Android to attached Emulator'
914-
- '- Desktop as MSIX to Windows'
915-
- '- Desktop as DEB to WSL'
912+
- Deploy all supported delivery targets from one orchestrated entry point.
913+
- Cover Director as a global dotnet tool, Web-UI as a global dotnet tool, Android to an attached phone, Android to an attached emulator, Desktop as MSIX to Windows, and Desktop as DEB to WSL.
914+
- Support both full-all deployment and targeted subset execution without requiring users to invoke individual build targets manually.
915+
- Support safe dry-run auditing with -WhatIf so users can inspect the full deployment plan without mutating local installations or attached devices.
916+
done-summary: Implemented and audited a unified deploy-all workflow covering Director, WebUi, AndroidPhone, AndroidEmulator, DesktopMsix, and DesktopDeb. Verified target selection, configuration overrides, WhatIf support, PowerShell compatibility helpers, documentation, three passing Pester tests, and a successful six-target WhatIf smoke run with zero failures.
917+
remaining: None. DEPLOY-ALLSCRIPT-001 was closed on 2026-03-16 after confirming six-target coverage, orchestration support, automated tests, documentation, and passing non-destructive validation.
918+
technical-details:
919+
- scripts\\deploy-all.ps1 is the PowerShell entry point and exposes validated target selection, configuration, package-version, Android serial, WSL distro, and WhatIf parameters.
920+
- scripts\\DeployAllTargets.psm1 provides the compatibility/orchestration module for PowerShell-based deploy-all target dispatch and summary reporting.
921+
- build\\Build.cs declares the DeployAll target and the shared deployment parameters consumed by NUKE.
922+
- build\\Build.BuildAndDeployTargets.cs implements DeployDirectorCore, DeployWebUiCore, DeployAndroidSelection, DeployDesktopMsixCore, DeployDesktopDebCore, ParseDeploySelections, ShowDeploySummary, and RunDeployAllTarget.
923+
- README.md documents the DeployAll workflow, target-selection examples, and validation commands.
924+
- Audit validation on 2026-03-16 passed with Invoke-Pester scripts\\DeployAllTargets.Tests.ps1 (3/3 tests) and scripts\\deploy-all.ps1 -WhatIf across Director, WebUi, AndroidPhone, AndroidEmulator, DesktopMsix, and DesktopDeb. The WhatIf run reported Failed=0; AndroidEmulator was skipped only because no attached emulator was present in this environment.
925+
implementation-tasks:
926+
- task: Provide a single deploy-all entry point that can orchestrate Director, WebUi, AndroidPhone, AndroidEmulator, DesktopMsix, and DesktopDeb.
927+
done: true
928+
- task: Support selective target execution instead of requiring all six targets on every run.
929+
done: true
930+
- task: Support deployment configuration overrides and target-specific parameters such as package version, Android serials, and WSL distro.
931+
done: true
932+
- task: Support safe dry-run auditing through WhatIf so the full deployment plan can be previewed without side effects.
933+
done: true
934+
- task: Add automated tests for deploy-all compatibility/orchestration behavior.
935+
done: true
936+
- task: Document the DeployAll workflow and validation commands for users.
937+
done: true
938+
- task: Audit the completed implementation against the MCP TODO scope and rerun non-destructive validation before closing the item.
939+
done: true
916940
medium-priority: []
917941
director:
918942
medium-priority:

nupkg/SharpNinja.McpServer.Web.nuspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<package xmlns="http://schemas.microsoft.com/packaging/2012/06/nuspec.xsd">
33
<metadata>
44
<id>SharpNinja.McpServer.Web</id>
5-
<version>0.5.1-30</version>
5+
<version>0.5.1-34</version>
66
<authors>SharpNinja</authors>
77
<description>SharpNinja.McpServer.Web</description>
88
<packageTypes>

src/McpServer.Director/McpServer.Director.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
<RepositoryType>git</RepositoryType>
2121
</PropertyGroup>
2222
<ItemGroup>
23+
<PackageReference Include="System.Text.Json" Version="10.0.3" />
2324
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
2425
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc3" />
2526
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />

src/McpServer.UI.Core/Commands/InvokeUiActionCommand.cs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,17 @@ public InvokeUiActionCommand(Func<Task> Action)
2626
public Func<Task> Action { get; }
2727
}
2828

29-
public sealed class InvokeUiActionHandler : ICommandHandler<InvokeUiActionCommand, bool>
29+
public sealed class InvokeUiActionHandler(Services.IUiDispatcherService uiDispatcher) : ICommandHandler<InvokeUiActionCommand, bool>
3030
{
3131
public async Task<Result<bool>> HandleAsync(InvokeUiActionCommand command, CallContext context)
3232
{
33-
if (Avalonia.Threading.Dispatcher.UIThread.CheckAccess())
33+
if (uiDispatcher.CheckAccess())
3434
{
3535
await command.Action().ConfigureAwait(true);
3636
return Result<bool>.Success(true);
3737
}
3838

39-
await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(async () =>
40-
await command.Action().ConfigureAwait(true)).ConfigureAwait(true);
39+
await uiDispatcher.InvokeAsync(command.Action).ConfigureAwait(true);
4140
return Result<bool>.Success(true);
4241
}
4342
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using System;
2+
using System.Threading.Tasks;
3+
4+
namespace McpServer.UI.Core.Services;
5+
6+
/// <summary>
7+
/// Strategy for marshaling work onto a host UI thread or synchronization context.
8+
/// </summary>
9+
public interface IUiDispatchStrategy
10+
{
11+
/// <summary>Returns <see langword="true"/> when the caller already has UI access.</summary>
12+
bool CheckAccess();
13+
14+
/// <summary>Invokes an asynchronous action on the UI context.</summary>
15+
Task InvokeAsync(Func<Task> action);
16+
17+
/// <summary>Posts a synchronous action to the UI context.</summary>
18+
void Post(Action action);
19+
}

src/McpServer.UI.Core/Services/IUiDispatcherService.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Threading.Tasks;
23

34
namespace McpServer.UI.Core.Services;
45

@@ -7,6 +8,12 @@ namespace McpServer.UI.Core.Services;
78
/// </summary>
89
public interface IUiDispatcherService
910
{
11+
/// <summary>Returns <see langword="true"/> when the caller is already on the UI context.</summary>
12+
bool CheckAccess();
13+
14+
/// <summary>Invokes an asynchronous action on the UI context.</summary>
15+
Task InvokeAsync(Func<Task> action);
16+
1017
/// <summary>Posts an action for execution on the UI context.</summary>
1118
void Post(Action action);
1219
}
Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,35 @@
11
using System;
2+
using System.Threading.Tasks;
23

34
namespace McpServer.UI.Core.Services;
45

56
/// <summary>
67
/// Default dispatcher that executes work inline when no UI dispatcher is supplied by host.
78
/// </summary>
8-
public sealed class ImmediateUiDispatcherService : IUiDispatcherService
9+
public sealed class ImmediateUiDispatcherService : StrategyUiDispatcherService
910
{
10-
/// <inheritdoc />
11-
public void Post(Action action) => action();
11+
/// <summary>
12+
/// Initializes the immediate dispatcher service.
13+
/// </summary>
14+
public ImmediateUiDispatcherService()
15+
: base(new ImmediateUiDispatchStrategy())
16+
{
17+
}
18+
}
19+
20+
internal sealed class ImmediateUiDispatchStrategy : IUiDispatchStrategy
21+
{
22+
public bool CheckAccess() => true;
23+
24+
public Task InvokeAsync(Func<Task> action)
25+
{
26+
ArgumentNullException.ThrowIfNull(action);
27+
return action();
28+
}
29+
30+
public void Post(Action action)
31+
{
32+
ArgumentNullException.ThrowIfNull(action);
33+
action();
34+
}
1235
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using System;
2+
using System.Threading.Tasks;
3+
4+
namespace McpServer.UI.Core.Services;
5+
6+
/// <summary>
7+
/// Reusable <see cref="IUiDispatcherService"/> implementation that delegates UI marshaling to a strategy.
8+
/// </summary>
9+
public class StrategyUiDispatcherService : IUiDispatcherService
10+
{
11+
private readonly IUiDispatchStrategy _strategy;
12+
13+
/// <summary>
14+
/// Initializes a strategy-backed UI dispatcher service.
15+
/// </summary>
16+
/// <param name="strategy">Framework-specific UI dispatch strategy.</param>
17+
public StrategyUiDispatcherService(IUiDispatchStrategy strategy)
18+
{
19+
ArgumentNullException.ThrowIfNull(strategy);
20+
_strategy = strategy;
21+
}
22+
23+
/// <summary>
24+
/// Gets the active UI dispatch strategy.
25+
/// </summary>
26+
protected IUiDispatchStrategy Strategy => _strategy;
27+
28+
/// <inheritdoc />
29+
public bool CheckAccess() => _strategy.CheckAccess();
30+
31+
/// <inheritdoc />
32+
public Task InvokeAsync(Func<Task> action) => _strategy.InvokeAsync(action);
33+
34+
/// <inheritdoc />
35+
public void Post(Action action) => _strategy.Post(action);
36+
}

0 commit comments

Comments
 (0)