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
70 changes: 61 additions & 9 deletions src/OpenClaw.Tray.WinUI/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2213,6 +2213,17 @@ private void OnPairingStatusChanged(object? sender, OpenClaw.Shared.PairingStatu
{
_toastService!.MarkPairedToastShown(deviceKey);
AddRecentActivity("Node paired", category: "node", dashboardPath: "nodes", nodeId: args.DeviceId);
AppNotificationPublisher.Show(
_appNotificationService,
LocalizationHelper.GetString("Toast_NodePaired"),
LocalizationHelper.GetString("Toast_NodePairedDetail"),
"node",
"pairing",
AppNotificationSeverity.Success,
"node-paired:" + HashNotificationKey(deviceKey),
"connection",
LocalizationHelper.GetString("AppNotification_ActionOpenConnection"),
id: BuildPairingPairedNotificationId(deviceKey));
_toastService!.ShowToast(new ToastContentBuilder()
.AddText(LocalizationHelper.GetString("Toast_NodePaired"))
.AddText(LocalizationHelper.GetString("Toast_NodePairedDetail")),
Expand Down Expand Up @@ -2269,6 +2280,9 @@ public static string BuildPairingApprovalCommand(string deviceId) =>
private static string BuildPairingPendingNotificationId(string deviceId) =>
$"node-pairing-pending:{deviceId.Trim().ToLowerInvariant()}";

private static string BuildPairingPairedNotificationId(string deviceId) =>
$"node-paired:{deviceId.Trim().ToLowerInvariant()}";

private static string BuildPairingRejectedNotificationId(string deviceId) =>
$"node-pairing-rejected:{deviceId.Trim().ToLowerInvariant()}";

Expand Down Expand Up @@ -2461,19 +2475,33 @@ private void OnNodeNotificationRequested(object? sender, OpenClaw.Shared.Capabil
// Agent requested a notification via node.invoke system.notify
try
{
_toastService!.ShowToast(new ToastContentBuilder()
.AddText(args.Title)
.AddText(args.Body));
AppNotificationPublisher.Publish(
_appNotificationService,
_toastService,
new AppNotificationPublishRequest(
AppNotificationMapper.FromNodeSystemNotification(args),
new ToastContentBuilder()
.AddText(args.Title)
.AddText(args.Body)));
}
catch (Exception ex)
{
Logger.Warn($"Failed to show node notification: {ex.Message}");
}
}

private void OnNodeToastRequested(object? sender, Microsoft.Toolkit.Uwp.Notifications.ToastContentBuilder builder)
private void OnNodeToastRequested(object? sender, NodeToastRequestedEventArgs args)
=> OnUiThread(() =>
NonFatalAction.Run(() => _toastService!.ShowToast(builder), msg => Logger.Warn($"Failed to show node toast: {msg}")));
NonFatalAction.Run(
() => AppNotificationPublisher.Publish(
_appNotificationService,
_toastService,
new AppNotificationPublishRequest(
args.AppNotification,
args.ToastBuilder,
args.ToastTag,
args.ToastDeviceId)),
msg => Logger.Warn($"Failed to show node toast: {msg}")));

private void OnLocalExecApprovalRequested(object? sender, ExecApprovalPromptRequestedEventArgs args)
{
Expand Down Expand Up @@ -2787,9 +2815,26 @@ private void OnGatewaySessionCommandCompleted(object? sender, SessionCommandResu
dashboardPath: !string.IsNullOrWhiteSpace(result.Key) ? $"sessions/{result.Key}" : "sessions",
sessionKey: result.Key);

_toastService!.ShowToast(new ToastContentBuilder()
.AddText(title)
.AddText(message));
AppNotification? appNotification = result.Ok
? null
: new AppNotification
{
Title = title,
Message = message,
Source = "session",
Category = "status",
Severity = AppNotificationSeverity.Error,
DedupeKey = "session-command:" + HashNotificationKey($"{result.Method}|{key}|{message}")
};

AppNotificationPublisher.Publish(
_appNotificationService,
_toastService,
new AppNotificationPublishRequest(
appNotification,
new ToastContentBuilder()
.AddText(title)
.AddText(message)));
}
catch (Exception ex)
{
Expand Down Expand Up @@ -2870,7 +2915,14 @@ private void OnGatewayNotificationReceived(object? sender, OpenClawNotification
.AddArgument("sessionKey", notification.SessionKey ?? ""));
}

_toastService!.ShowToast(builder);
AppNotificationPublisher.Publish(
_appNotificationService,
_toastService,
new AppNotificationPublishRequest(
AppNotificationMapper.FromGatewayNotification(
notification,
LocalizationHelper.GetString("AppNotification_ExecApprovalPending_OpenChatAction")),
builder));
}
catch (Exception ex)
{
Expand Down
116 changes: 116 additions & 0 deletions src/OpenClaw.Tray.WinUI/Services/AppNotificationMapper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using OpenClaw.Shared;
using OpenClaw.Shared.Capabilities;
using System;
using System.Security.Cryptography;
using System.Text;

namespace OpenClawTray.Services;

internal static class AppNotificationMapper
{
public static AppNotification FromGatewayNotification(OpenClawNotification notification, string? chatActionLabel = null)
{
ArgumentNullException.ThrowIfNull(notification);

var title = NormalizeTitle(notification.Title);
var message = NormalizeMessage(notification.Message, title);
var category = NormalizeCategory(notification.Type);
var hasChatAction = notification.IsChat && !string.IsNullOrWhiteSpace(chatActionLabel);

return new AppNotification
{
Title = title,
Message = message,
Source = "gateway",
Category = category,
Severity = SeverityFromGatewayType(category),
DedupeKey = BuildDedupeKey(
"gateway",
notification.Type,
notification.Title,
notification.Message,
notification.SessionKey),
ActionLabel = hasChatAction ? chatActionLabel : null,
ActionRoute = hasChatAction ? GetChatActionRoute(notification.SessionKey) : null
};
}

public static AppNotification FromNodeSystemNotification(SystemNotifyArgs args)
{
ArgumentNullException.ThrowIfNull(args);

var title = NormalizeTitle(args.Title);
var message = NormalizeMessage(args.Body, title);

return new AppNotification
{
Title = title,
Message = message,
Source = "node",
Category = "system.notify",
Severity = SeverityFromText(title, message),
DedupeKey = BuildDedupeKey("node-system-notify", args.Title, args.Body)
};
}

public static AppNotification FromNodeActivity(
string title,
string message,
string category,
AppNotificationSeverity severity,
string dedupeKey)
{
ArgumentException.ThrowIfNullOrWhiteSpace(category);
ArgumentException.ThrowIfNullOrWhiteSpace(dedupeKey);

var normalizedTitle = NormalizeTitle(title);
return new AppNotification
{
Title = normalizedTitle,
Message = NormalizeMessage(message, normalizedTitle),
Source = "node",
Category = category.Trim(),
Severity = severity,
DedupeKey = dedupeKey.Trim()
};
}

private static AppNotificationSeverity SeverityFromGatewayType(string? type) => type?.Trim().ToLowerInvariant() switch
{
"error" => AppNotificationSeverity.Error,
"urgent" or "health" => AppNotificationSeverity.Warning,
_ => AppNotificationSeverity.Informational
};

private static AppNotificationSeverity SeverityFromText(string title, string message)
{
var text = string.Concat(title, " ", message);
return text.Contains("error", StringComparison.OrdinalIgnoreCase) ||
text.Contains("failed", StringComparison.OrdinalIgnoreCase) ||
text.Contains("blocked", StringComparison.OrdinalIgnoreCase) ||
text.Contains("denied", StringComparison.OrdinalIgnoreCase)
? AppNotificationSeverity.Error
: AppNotificationSeverity.Informational;
}

private static string NormalizeTitle(string? title) =>
string.IsNullOrWhiteSpace(title) ? "OpenClaw" : title.Trim();

private static string NormalizeMessage(string? message, string title) =>
string.IsNullOrWhiteSpace(message) ? title : message.Trim();

private static string NormalizeCategory(string? category) =>
string.IsNullOrWhiteSpace(category) ? "info" : category.Trim();

private static string GetChatActionRoute(string? sessionKey) =>
string.IsNullOrWhiteSpace(sessionKey)
? "chat"
: AppNotificationActionRoutes.Chat(sessionKey);

private static string BuildDedupeKey(string scope, params string?[] parts)
{
var raw = string.Join("\u001f", parts.Select(part => part?.Trim() ?? string.Empty));
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw))).ToLowerInvariant();
return $"{scope}:{hash}";
}
}
71 changes: 71 additions & 0 deletions src/OpenClaw.Tray.WinUI/Services/AppNotificationPublisher.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,78 @@
using Microsoft.Toolkit.Uwp.Notifications;
using System;
using System.Collections.Generic;
using System.Runtime.ExceptionServices;

namespace OpenClawTray.Services;

internal sealed record AppNotificationPublishRequest(
AppNotification? AppNotification = null,
ToastContentBuilder? ToastBuilder = null,
string? ToastTag = null,
string? ToastDeviceId = null);

internal interface IToastNotificationPublisher
{
void ShowToast(ToastContentBuilder builder, string? toastTag = null, string? deviceId = null);
}

internal static class AppNotificationPublisher
{
public static void Publish(
AppNotificationService? appNotificationService,
IToastNotificationPublisher? toastService,
AppNotificationPublishRequest request)
{
Action<ToastContentBuilder, string?, string?>? showToast = toastService is null
? null
: (builder, tag, deviceId) => toastService.ShowToast(builder, tag, deviceId);
Publish(appNotificationService, showToast, request);
}

internal static void Publish(
AppNotificationService? appNotificationService,
Action<ToastContentBuilder, string?, string?>? showToast,
AppNotificationPublishRequest request)
{
ArgumentNullException.ThrowIfNull(request);
if (request.AppNotification is null && request.ToastBuilder is null)
throw new ArgumentException("Publish request must include an app notification, a toast builder, or both.", nameof(request));

List<Exception>? failures = null;

if (request.ToastBuilder is not null && showToast is not null)
{
try
{
showToast(request.ToastBuilder, request.ToastTag, request.ToastDeviceId);
}
catch (Exception ex)
{
(failures ??= new()).Add(ex);
}
}

if (request.AppNotification is not null && appNotificationService is not null)
{
try
{
appNotificationService.Show(request.AppNotification);
}
catch (Exception ex)
{
(failures ??= new()).Add(ex);
}
}

if (failures is null)
return;

if (failures.Count == 1)
ExceptionDispatchInfo.Capture(failures[0]).Throw();

throw new AggregateException(failures);
}

public static void Show(
AppNotificationService? service,
string title,
Expand Down
9 changes: 9 additions & 0 deletions src/OpenClaw.Tray.WinUI/Services/AppNotificationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ public sealed class AppNotificationChangedEventArgs(AppNotificationSnapshot snap

internal sealed class AppNotificationService
{
private const int MaxActiveNotifications = 100;
private readonly object _gate = new();
private readonly List<AppNotification> _queue = new();
private AppNotification? _current;
Expand Down Expand Up @@ -170,6 +171,7 @@ public void Show(AppNotification notification)
else
{
_queue.Add(normalized);
PruneQueueLocked();
snapshot = CreateSnapshotLocked();
}
}
Expand Down Expand Up @@ -378,6 +380,13 @@ private AppNotification DequeueLocked()
return next;
}

private void PruneQueueLocked()
{
var maxQueued = _current is null ? MaxActiveNotifications : MaxActiveNotifications - 1;
while (_queue.Count > maxQueued)
_queue.RemoveAt(0);
}

private AppNotificationSnapshot CreateSnapshotLocked()
{
var queued = _queue.ToList();
Expand Down
Loading
Loading