Skip to content

Commit 6a85aaa

Browse files
committed
Add file transfer, agents panel, connection settings, toolbar TextBlock fix, UpdateAgentRunner error logging
1 parent d74983d commit 6a85aaa

32 files changed

Lines changed: 3878 additions & 689 deletions

.vscode/settings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@
55
"path": "pwsh.exe",
66
"args": ["-NoProfile"]
77
}
8-
}
8+
},
9+
"fusion-360-helper.enabled": false
910
}

GitVersion.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
# See https://gitversion.net/docs/reference/configuration
33
workflow: GitHubFlow/v1
44
# Start at 1.0.0 when no version tag exists yet
5-
next-version: 0.1.19
5+
next-version: 0.1.23
6+
7+
8+
9+
610

711

812

src/RemoteAgent.App.Logic/AgentSessionClient.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public interface IAgentSessionClient : IAgentInteractionSession
1313
string? PerRequestContext { get; set; }
1414
event Action? ConnectionStateChanged;
1515
event Action<ChatMessage>? MessageReceived;
16+
event Action<FileTransfer>? FileTransferReceived;
1617

1718
Task ConnectAsync(
1819
string host,
@@ -47,6 +48,7 @@ public sealed class AgentSessionClient(Func<MediaChunk, string>? mediaTextFormat
4748

4849
public event Action? ConnectionStateChanged;
4950
public event Action<ChatMessage>? MessageReceived;
51+
public event Action<FileTransfer>? FileTransferReceived;
5052

5153
public async Task ConnectAsync(
5254
string host,
@@ -207,6 +209,23 @@ private async Task ReceiveLoop(CancellationToken ct)
207209
while (await _call.ResponseStream.MoveNext(ct))
208210
{
209211
var incoming = _call.ResponseStream.Current;
212+
213+
// Handle file transfers separately: raise dedicated event + chat notification.
214+
if (incoming.PayloadCase == ServerMessage.PayloadOneofCase.FileTransfer && incoming.FileTransfer != null)
215+
{
216+
var ft = incoming.FileTransfer;
217+
FileTransferReceived?.Invoke(ft);
218+
var sizeText = ft.TotalSize < 1024 ? $"{ft.TotalSize} B" : $"{ft.TotalSize / 1024.0:F1} KB";
219+
var chatMsg = new ChatMessage
220+
{
221+
IsUser = false,
222+
Text = $"\U0001F4C1 File received: {ft.RelativePath} ({sizeText})",
223+
FileTransferPath = ft.RelativePath
224+
};
225+
MessageReceived?.Invoke(chatMsg);
226+
continue;
227+
}
228+
210229
var mapped = MapServerMessage(incoming);
211230
if (mapped != null)
212231
MessageReceived?.Invoke(mapped);

src/RemoteAgent.App.Logic/ChatMessage.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ public class ChatMessage : INotifyPropertyChanged
3939
/// <summary>Message priority (FR-3.1). Notify causes a system notification; tap opens the app (FR-3.2, FR-3.3).</summary>
4040
public ChatMessagePriority Priority { get; init; } = ChatMessagePriority.Normal;
4141

42+
/// <summary>When set, indicates this message represents a file transfer. Contains the relative path of the transferred file.</summary>
43+
public string? FileTransferPath { get; init; }
44+
4245
/// <summary>Plain text for display: event message or raw text (no markdown).</summary>
4346
public string DisplayText => IsEvent ? (EventMessage ?? "") : Text;
4447

src/RemoteAgent.App.Logic/ServerApiClient.cs

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -351,11 +351,53 @@ private static InvalidOperationException CreateGrpcFailure(string operation, Rpc
351351
return new Metadata { { "x-api-key", apiKey.Trim() } };
352352
}
353353

354-
public static string BuildBaseUrl(string host, int port)
355-
{
356-
return port == 443 ? $"https://{host}" : $"http://{host}:{port}";
357-
}
358-
}
354+
public static string BuildBaseUrl(string host, int port)
355+
{
356+
return port == 443 ? $"https://{host}" : $"http://{host}:{port}";
357+
}
358+
359+
public static Task<ListAgentRunnersResponse?> ListAgentRunnersAsync(
360+
string host,
361+
int port,
362+
string? apiKey = null,
363+
CancellationToken ct = default,
364+
bool throwOnError = false)
365+
=> ExecuteGrpcAsync(
366+
host,
367+
port,
368+
apiKey,
369+
"List agent runners",
370+
throwOnError,
371+
ct,
372+
(client, headers, token) => client.ListAgentRunnersAsync(new ListAgentRunnersRequest(), headers, deadline: null, cancellationToken: token).ResponseAsync);
373+
374+
public static Task<UpdateAgentRunnerResponse?> UpdateAgentRunnerAsync(
375+
string host,
376+
int port,
377+
string runnerId,
378+
string? command = null,
379+
string? arguments = null,
380+
int maxConcurrentSessions = 0,
381+
bool setAsDefault = false,
382+
string? apiKey = null,
383+
CancellationToken ct = default,
384+
bool throwOnError = false)
385+
=> ExecuteGrpcAsync(
386+
host,
387+
port,
388+
apiKey,
389+
"Update agent runner",
390+
throwOnError,
391+
ct,
392+
(client, headers, token) => client.UpdateAgentRunnerAsync(new UpdateAgentRunnerRequest
393+
{
394+
RunnerId = runnerId,
395+
Command = command ?? "",
396+
Arguments = arguments ?? "",
397+
MaxConcurrentSessions = maxConcurrentSessions,
398+
SetAsDefault = setAsDefault
399+
}, headers, deadline: null, cancellationToken: token).ResponseAsync);
400+
}
359401

360402
public sealed record SessionCapacitySnapshot(
361403
bool CanCreateSession,

src/RemoteAgent.App/Services/AgentGatewayClientService.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public AgentGatewayClientService(ILocalMessageStore? store = null)
1717
_sessionClient = new AgentSessionClient(FormatReceivedMedia);
1818
_sessionClient.ConnectionStateChanged += () => ConnectionStateChanged?.Invoke();
1919
_sessionClient.MessageReceived += OnSessionClientMessageReceived;
20+
_sessionClient.FileTransferReceived += OnFileTransferReceived;
2021
}
2122

2223
public ObservableCollection<ChatMessage> Messages { get; } = new();
@@ -109,6 +110,18 @@ private void OnSessionClientMessageReceived(ChatMessage chat)
109110
});
110111
}
111112

113+
private static void OnFileTransferReceived(FileTransfer fileTransfer)
114+
{
115+
try
116+
{
117+
FileSaveService.SaveFileTransfer(fileTransfer);
118+
}
119+
catch
120+
{
121+
// File save failures are non-fatal; the chat message already shows the transfer.
122+
}
123+
}
124+
112125
private static string FormatReceivedMedia(MediaChunk media)
113126
{
114127
try
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using RemoteAgent.Proto;
2+
3+
namespace RemoteAgent.App.Services;
4+
5+
/// <summary>Saves files received via <see cref="FileTransfer"/> to the device, preserving the relative path hierarchy
6+
/// under the app data directory. On Android, files are stored under AppDataDirectory/RemoteAgent/Files/.</summary>
7+
public static class FileSaveService
8+
{
9+
/// <summary>Saves a file transfer to local storage preserving the directory hierarchy from
10+
/// <see cref="FileTransfer.RelativePath"/>. Returns the saved path for display, or null on failure.</summary>
11+
public static string? SaveFileTransfer(FileTransfer fileTransfer)
12+
{
13+
if (fileTransfer.Content == null || fileTransfer.Content.Length == 0)
14+
return null;
15+
16+
var relativePath = fileTransfer.RelativePath;
17+
if (string.IsNullOrWhiteSpace(relativePath))
18+
relativePath = $"file_{DateTime.UtcNow:yyyyMMdd_HHmmss}.bin";
19+
20+
// Normalize to forward slashes and sanitize
21+
relativePath = relativePath.Replace('\\', '/');
22+
23+
// Build the full path under AppDataDirectory/RemoteAgent/Files/
24+
var basePath = Path.Combine(FileSystem.AppDataDirectory, "RemoteAgent", "Files");
25+
var fullPath = Path.Combine(basePath, relativePath.Replace('/', Path.DirectorySeparatorChar));
26+
27+
try
28+
{
29+
var directory = Path.GetDirectoryName(fullPath);
30+
if (!string.IsNullOrEmpty(directory))
31+
Directory.CreateDirectory(directory);
32+
33+
File.WriteAllBytes(fullPath, fileTransfer.Content.ToByteArray());
34+
return relativePath;
35+
}
36+
catch
37+
{
38+
return null;
39+
}
40+
}
41+
}

src/RemoteAgent.Desktop/App.axaml.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,8 @@ private static void ConfigureServices(IServiceCollection services)
146146
services.AddTransient<IRequestHandler<Requests.SaveAppLogRequest, CommandResult>, SaveAppLogHandler>();
147147
services.AddTransient<IRequestHandler<Requests.CopyStatusLogRequest, CommandResult>, CopyStatusLogHandler>();
148148
services.AddTransient<IRequestHandler<Requests.OpenLogsFolderRequest, CommandResult>, OpenLogsFolderHandler>();
149-
services.AddTransient<IRequestHandler<Requests.SetPairingUserRequest, CommandResult>, SetPairingUserHandler>();
149+
services.AddTransient<IRequestHandler<Requests.RefreshAgentsRequest, CommandResult>, RefreshAgentsHandler>();
150+
services.AddTransient<IRequestHandler<Requests.SetPairingUserRequest, CommandResult>, SetPairingUserHandler>();
150151
services.AddSingleton<IPairingUserDialog>(sp => new AvaloniaPairingUserDialog());
151152

152153
services.AddSingleton<MainWindowViewModel>();

src/RemoteAgent.Desktop/Handlers/OpenNewSessionHandler.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,19 @@ public async Task<CommandResult> HandleAsync(
1818
return CommandResult.Fail("No owner window available.");
1919

2020
var workspace = request.Workspace;
21+
var availableAgents = workspace.Agents.Agents
22+
.Select(a => a.AgentId)
23+
.ToList();
24+
2125
var defaults = new ConnectionSettingsDefaults(
2226
workspace.Host,
2327
workspace.Port,
2428
workspace.SelectedConnectionMode,
2529
workspace.SelectedAgentId,
2630
workspace.ApiKey,
2731
workspace.PerRequestContext,
28-
workspace.ConnectionModes);
32+
workspace.ConnectionModes,
33+
availableAgents);
2934

3035
var result = await dialogService.ShowAsync(ownerWindow, defaults, cancellationToken);
3136
if (result is null)
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
using RemoteAgent.App.Logic;
2+
using RemoteAgent.App.Logic.Cqrs;
3+
using RemoteAgent.Desktop.Infrastructure;
4+
using RemoteAgent.Desktop.Requests;
5+
6+
namespace RemoteAgent.Desktop.Handlers;
7+
8+
public sealed class RefreshAgentsHandler(IServerCapacityClient client)
9+
: IRequestHandler<RefreshAgentsRequest, CommandResult>
10+
{
11+
public async Task<CommandResult> HandleAsync(RefreshAgentsRequest request, CancellationToken cancellationToken = default)
12+
{
13+
// Try the new ListAgentRunners API first (provides full runner details).
14+
try
15+
{
16+
var runnersResponse = await ServerApiClient.ListAgentRunnersAsync(
17+
request.Host, request.Port, request.ApiKey, cancellationToken, throwOnError: true);
18+
19+
if (runnersResponse != null)
20+
{
21+
// Also get server version via GetServerInfo.
22+
var serverInfo = await client.GetServerInfoAsync(request.Host, request.Port, request.ApiKey, cancellationToken);
23+
request.Workspace.ServerVersion = serverInfo?.ServerVersion ?? "";
24+
25+
var agents = new List<AgentSnapshot>();
26+
foreach (var runner in runnersResponse.Runners)
27+
{
28+
var isDefault = string.Equals(runner.RunnerId, request.CurrentDefaultAgentId, StringComparison.OrdinalIgnoreCase)
29+
|| runner.IsDefault;
30+
var remaining = runner.MaxConcurrentSessions > 0
31+
? runner.MaxConcurrentSessions - runner.ActiveSessionCount
32+
: (int?)null;
33+
agents.Add(new AgentSnapshot(
34+
runner.RunnerId,
35+
runner.ActiveSessionCount,
36+
runner.MaxConcurrentSessions > 0 ? runner.MaxConcurrentSessions : null,
37+
remaining,
38+
isDefault,
39+
runner.RunnerType,
40+
runner.Command,
41+
runner.Arguments,
42+
runner.Description));
43+
}
44+
45+
request.Workspace.Agents.Clear();
46+
foreach (var agent in agents)
47+
request.Workspace.Agents.Add(agent);
48+
49+
request.Workspace.SelectedAgent = request.Workspace.Agents.FirstOrDefault(a => a.IsDefault)
50+
?? request.Workspace.Agents.FirstOrDefault();
51+
52+
request.Workspace.AgentsStatus = $"Loaded {agents.Count} agent runner(s) from server v{request.Workspace.ServerVersion}.";
53+
return CommandResult.Ok();
54+
}
55+
}
56+
catch
57+
{
58+
// Fall through to legacy approach if ListAgentRunners is not available.
59+
}
60+
61+
// Legacy fallback: use GetServerInfo + per-agent CheckSessionCapacity.
62+
var info = await client.GetServerInfoAsync(request.Host, request.Port, request.ApiKey, cancellationToken);
63+
if (info == null)
64+
{
65+
request.Workspace.AgentsStatus = "Failed to retrieve server info.";
66+
return CommandResult.Fail("Failed to retrieve server info.");
67+
}
68+
69+
request.Workspace.ServerVersion = info.ServerVersion;
70+
71+
var legacyAgents = new List<AgentSnapshot>();
72+
foreach (var agentId in info.AvailableAgents)
73+
{
74+
var isDefault = string.Equals(agentId, request.CurrentDefaultAgentId, StringComparison.OrdinalIgnoreCase);
75+
try
76+
{
77+
var capacity = await client.GetCapacityAsync(request.Host, request.Port, agentId, request.ApiKey, cancellationToken);
78+
if (capacity != null)
79+
{
80+
legacyAgents.Add(new AgentSnapshot(
81+
agentId,
82+
capacity.AgentActiveSessionCount,
83+
capacity.AgentMaxConcurrentSessions,
84+
capacity.RemainingAgentCapacity,
85+
isDefault));
86+
}
87+
else
88+
{
89+
legacyAgents.Add(new AgentSnapshot(agentId, 0, null, null, isDefault));
90+
}
91+
}
92+
catch
93+
{
94+
legacyAgents.Add(new AgentSnapshot(agentId, 0, null, null, isDefault));
95+
}
96+
}
97+
98+
request.Workspace.Agents.Clear();
99+
foreach (var agent in legacyAgents)
100+
request.Workspace.Agents.Add(agent);
101+
102+
request.Workspace.SelectedAgent = request.Workspace.Agents.FirstOrDefault(a => a.IsDefault)
103+
?? request.Workspace.Agents.FirstOrDefault();
104+
105+
request.Workspace.AgentsStatus = $"Loaded {legacyAgents.Count} agent(s) from server v{info.ServerVersion}.";
106+
return CommandResult.Ok();
107+
}
108+
}

0 commit comments

Comments
 (0)