Skip to content

Commit 1da2ed8

Browse files
RBridCopilot
authored andcommitted
Mirror tray notifications in app
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent dd1c791 commit 1da2ed8

11 files changed

Lines changed: 517 additions & 35 deletions

src/OpenClaw.Tray.WinUI/App.xaml.cs

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2213,6 +2213,17 @@ private void OnPairingStatusChanged(object? sender, OpenClaw.Shared.PairingStatu
22132213
{
22142214
_toastService!.MarkPairedToastShown(deviceKey);
22152215
AddRecentActivity("Node paired", category: "node", dashboardPath: "nodes", nodeId: args.DeviceId);
2216+
AppNotificationPublisher.Show(
2217+
_appNotificationService,
2218+
LocalizationHelper.GetString("Toast_NodePaired"),
2219+
LocalizationHelper.GetString("Toast_NodePairedDetail"),
2220+
"node",
2221+
"pairing",
2222+
AppNotificationSeverity.Success,
2223+
"node-paired:" + HashNotificationKey(deviceKey),
2224+
"connection",
2225+
LocalizationHelper.GetString("AppNotification_ActionOpenConnection"),
2226+
id: BuildPairingPairedNotificationId(deviceKey));
22162227
_toastService!.ShowToast(new ToastContentBuilder()
22172228
.AddText(LocalizationHelper.GetString("Toast_NodePaired"))
22182229
.AddText(LocalizationHelper.GetString("Toast_NodePairedDetail")),
@@ -2269,6 +2280,9 @@ public static string BuildPairingApprovalCommand(string deviceId) =>
22692280
private static string BuildPairingPendingNotificationId(string deviceId) =>
22702281
$"node-pairing-pending:{deviceId.Trim().ToLowerInvariant()}";
22712282

2283+
private static string BuildPairingPairedNotificationId(string deviceId) =>
2284+
$"node-paired:{deviceId.Trim().ToLowerInvariant()}";
2285+
22722286
private static string BuildPairingRejectedNotificationId(string deviceId) =>
22732287
$"node-pairing-rejected:{deviceId.Trim().ToLowerInvariant()}";
22742288

@@ -2461,19 +2475,33 @@ private void OnNodeNotificationRequested(object? sender, OpenClaw.Shared.Capabil
24612475
// Agent requested a notification via node.invoke system.notify
24622476
try
24632477
{
2464-
_toastService!.ShowToast(new ToastContentBuilder()
2465-
.AddText(args.Title)
2466-
.AddText(args.Body));
2478+
AppNotificationPublisher.Publish(
2479+
_appNotificationService,
2480+
_toastService,
2481+
new AppNotificationPublishRequest(
2482+
AppNotificationMapper.FromNodeSystemNotification(args),
2483+
new ToastContentBuilder()
2484+
.AddText(args.Title)
2485+
.AddText(args.Body)));
24672486
}
24682487
catch (Exception ex)
24692488
{
24702489
Logger.Warn($"Failed to show node notification: {ex.Message}");
24712490
}
24722491
}
24732492

2474-
private void OnNodeToastRequested(object? sender, Microsoft.Toolkit.Uwp.Notifications.ToastContentBuilder builder)
2493+
private void OnNodeToastRequested(object? sender, NodeToastRequestedEventArgs args)
24752494
=> OnUiThread(() =>
2476-
NonFatalAction.Run(() => _toastService!.ShowToast(builder), msg => Logger.Warn($"Failed to show node toast: {msg}")));
2495+
NonFatalAction.Run(
2496+
() => AppNotificationPublisher.Publish(
2497+
_appNotificationService,
2498+
_toastService,
2499+
new AppNotificationPublishRequest(
2500+
args.AppNotification,
2501+
args.ToastBuilder,
2502+
args.ToastTag,
2503+
args.ToastDeviceId)),
2504+
msg => Logger.Warn($"Failed to show node toast: {msg}")));
24772505

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

2790-
_toastService!.ShowToast(new ToastContentBuilder()
2791-
.AddText(title)
2792-
.AddText(message));
2818+
AppNotification? appNotification = result.Ok
2819+
? null
2820+
: new AppNotification
2821+
{
2822+
Title = title,
2823+
Message = message,
2824+
Source = "session",
2825+
Category = "status",
2826+
Severity = AppNotificationSeverity.Error,
2827+
DedupeKey = "session-command:" + HashNotificationKey($"{result.Method}|{key}|{message}")
2828+
};
2829+
2830+
AppNotificationPublisher.Publish(
2831+
_appNotificationService,
2832+
_toastService,
2833+
new AppNotificationPublishRequest(
2834+
appNotification,
2835+
new ToastContentBuilder()
2836+
.AddText(title)
2837+
.AddText(message)));
27932838
}
27942839
catch (Exception ex)
27952840
{
@@ -2870,7 +2915,14 @@ private void OnGatewayNotificationReceived(object? sender, OpenClawNotification
28702915
.AddArgument("sessionKey", notification.SessionKey ?? ""));
28712916
}
28722917

2873-
_toastService!.ShowToast(builder);
2918+
AppNotificationPublisher.Publish(
2919+
_appNotificationService,
2920+
_toastService,
2921+
new AppNotificationPublishRequest(
2922+
AppNotificationMapper.FromGatewayNotification(
2923+
notification,
2924+
LocalizationHelper.GetString("AppNotification_ExecApprovalPending_OpenChatAction")),
2925+
builder));
28742926
}
28752927
catch (Exception ex)
28762928
{
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
using OpenClaw.Shared;
2+
using OpenClaw.Shared.Capabilities;
3+
using System;
4+
using System.Security.Cryptography;
5+
using System.Text;
6+
7+
namespace OpenClawTray.Services;
8+
9+
internal static class AppNotificationMapper
10+
{
11+
public static AppNotification FromGatewayNotification(OpenClawNotification notification, string? chatActionLabel = null)
12+
{
13+
ArgumentNullException.ThrowIfNull(notification);
14+
15+
var title = NormalizeTitle(notification.Title);
16+
var message = NormalizeMessage(notification.Message, title);
17+
var category = NormalizeCategory(notification.Type);
18+
var hasChatAction = notification.IsChat && !string.IsNullOrWhiteSpace(chatActionLabel);
19+
20+
return new AppNotification
21+
{
22+
Title = title,
23+
Message = message,
24+
Source = "gateway",
25+
Category = category,
26+
Severity = SeverityFromGatewayType(category),
27+
DedupeKey = BuildDedupeKey(
28+
"gateway",
29+
notification.Type,
30+
notification.Title,
31+
notification.Message,
32+
notification.SessionKey),
33+
ActionLabel = hasChatAction ? chatActionLabel : null,
34+
ActionRoute = hasChatAction ? GetChatActionRoute(notification.SessionKey) : null
35+
};
36+
}
37+
38+
public static AppNotification FromNodeSystemNotification(SystemNotifyArgs args)
39+
{
40+
ArgumentNullException.ThrowIfNull(args);
41+
42+
var title = NormalizeTitle(args.Title);
43+
var message = NormalizeMessage(args.Body, title);
44+
45+
return new AppNotification
46+
{
47+
Title = title,
48+
Message = message,
49+
Source = "node",
50+
Category = "system.notify",
51+
Severity = SeverityFromText(title, message),
52+
DedupeKey = BuildDedupeKey("node-system-notify", args.Title, args.Body)
53+
};
54+
}
55+
56+
public static AppNotification FromNodeActivity(
57+
string title,
58+
string message,
59+
string category,
60+
AppNotificationSeverity severity,
61+
string dedupeKey)
62+
{
63+
ArgumentException.ThrowIfNullOrWhiteSpace(category);
64+
ArgumentException.ThrowIfNullOrWhiteSpace(dedupeKey);
65+
66+
var normalizedTitle = NormalizeTitle(title);
67+
return new AppNotification
68+
{
69+
Title = normalizedTitle,
70+
Message = NormalizeMessage(message, normalizedTitle),
71+
Source = "node",
72+
Category = category.Trim(),
73+
Severity = severity,
74+
DedupeKey = dedupeKey.Trim()
75+
};
76+
}
77+
78+
private static AppNotificationSeverity SeverityFromGatewayType(string? type) => type?.Trim().ToLowerInvariant() switch
79+
{
80+
"error" => AppNotificationSeverity.Error,
81+
"urgent" or "health" => AppNotificationSeverity.Warning,
82+
_ => AppNotificationSeverity.Informational
83+
};
84+
85+
private static AppNotificationSeverity SeverityFromText(string title, string message)
86+
{
87+
var text = string.Concat(title, " ", message);
88+
return text.Contains("error", StringComparison.OrdinalIgnoreCase) ||
89+
text.Contains("failed", StringComparison.OrdinalIgnoreCase) ||
90+
text.Contains("blocked", StringComparison.OrdinalIgnoreCase) ||
91+
text.Contains("denied", StringComparison.OrdinalIgnoreCase)
92+
? AppNotificationSeverity.Error
93+
: AppNotificationSeverity.Informational;
94+
}
95+
96+
private static string NormalizeTitle(string? title) =>
97+
string.IsNullOrWhiteSpace(title) ? "OpenClaw" : title.Trim();
98+
99+
private static string NormalizeMessage(string? message, string title) =>
100+
string.IsNullOrWhiteSpace(message) ? title : message.Trim();
101+
102+
private static string NormalizeCategory(string? category) =>
103+
string.IsNullOrWhiteSpace(category) ? "info" : category.Trim();
104+
105+
private static string GetChatActionRoute(string? sessionKey) =>
106+
string.IsNullOrWhiteSpace(sessionKey)
107+
? "chat"
108+
: AppNotificationActionRoutes.Chat(sessionKey);
109+
110+
private static string BuildDedupeKey(string scope, params string?[] parts)
111+
{
112+
var raw = string.Join("\u001f", parts.Select(part => part?.Trim() ?? string.Empty));
113+
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw))).ToLowerInvariant();
114+
return $"{scope}:{hash}";
115+
}
116+
}

src/OpenClaw.Tray.WinUI/Services/AppNotificationPublisher.cs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,78 @@
1+
using Microsoft.Toolkit.Uwp.Notifications;
2+
using System;
3+
using System.Collections.Generic;
4+
using System.Runtime.ExceptionServices;
5+
16
namespace OpenClawTray.Services;
27

8+
internal sealed record AppNotificationPublishRequest(
9+
AppNotification? AppNotification = null,
10+
ToastContentBuilder? ToastBuilder = null,
11+
string? ToastTag = null,
12+
string? ToastDeviceId = null);
13+
14+
internal interface IToastNotificationPublisher
15+
{
16+
void ShowToast(ToastContentBuilder builder, string? toastTag = null, string? deviceId = null);
17+
}
18+
319
internal static class AppNotificationPublisher
420
{
21+
public static void Publish(
22+
AppNotificationService? appNotificationService,
23+
IToastNotificationPublisher? toastService,
24+
AppNotificationPublishRequest request)
25+
{
26+
Action<ToastContentBuilder, string?, string?>? showToast = toastService is null
27+
? null
28+
: (builder, tag, deviceId) => toastService.ShowToast(builder, tag, deviceId);
29+
Publish(appNotificationService, showToast, request);
30+
}
31+
32+
internal static void Publish(
33+
AppNotificationService? appNotificationService,
34+
Action<ToastContentBuilder, string?, string?>? showToast,
35+
AppNotificationPublishRequest request)
36+
{
37+
ArgumentNullException.ThrowIfNull(request);
38+
if (request.AppNotification is null && request.ToastBuilder is null)
39+
throw new ArgumentException("Publish request must include an app notification, a toast builder, or both.", nameof(request));
40+
41+
List<Exception>? failures = null;
42+
43+
if (request.ToastBuilder is not null && showToast is not null)
44+
{
45+
try
46+
{
47+
showToast(request.ToastBuilder, request.ToastTag, request.ToastDeviceId);
48+
}
49+
catch (Exception ex)
50+
{
51+
(failures ??= new()).Add(ex);
52+
}
53+
}
54+
55+
if (request.AppNotification is not null && appNotificationService is not null)
56+
{
57+
try
58+
{
59+
appNotificationService.Show(request.AppNotification);
60+
}
61+
catch (Exception ex)
62+
{
63+
(failures ??= new()).Add(ex);
64+
}
65+
}
66+
67+
if (failures is null)
68+
return;
69+
70+
if (failures.Count == 1)
71+
ExceptionDispatchInfo.Capture(failures[0]).Throw();
72+
73+
throw new AggregateException(failures);
74+
}
75+
576
public static void Show(
677
AppNotificationService? service,
778
string title,

src/OpenClaw.Tray.WinUI/Services/AppNotificationService.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ public sealed class AppNotificationChangedEventArgs(AppNotificationSnapshot snap
135135

136136
internal sealed class AppNotificationService
137137
{
138+
private const int MaxActiveNotifications = 100;
138139
private readonly object _gate = new();
139140
private readonly List<AppNotification> _queue = new();
140141
private AppNotification? _current;
@@ -170,6 +171,7 @@ public void Show(AppNotification notification)
170171
else
171172
{
172173
_queue.Add(normalized);
174+
PruneQueueLocked();
173175
snapshot = CreateSnapshotLocked();
174176
}
175177
}
@@ -378,6 +380,13 @@ private AppNotification DequeueLocked()
378380
return next;
379381
}
380382

383+
private void PruneQueueLocked()
384+
{
385+
var maxQueued = _current is null ? MaxActiveNotifications : MaxActiveNotifications - 1;
386+
while (_queue.Count > maxQueued)
387+
_queue.RemoveAt(0);
388+
}
389+
381390
private AppNotificationSnapshot CreateSnapshotLocked()
382391
{
383392
var queued = _queue.ToList();

0 commit comments

Comments
 (0)