From 72f76c50b1c4b5e39a0189a434df7190e7e19347 Mon Sep 17 00:00:00 2001 From: QQ Date: Sun, 31 May 2026 19:42:09 +0800 Subject: [PATCH 1/2] Thread session key through chat notifications Add SessionKey property to OpenClawNotification so toast activations can route to the specific session that produced the assistant response. Update EmitChatNotification to accept and populate the session key from the gateway chat event payload. --- src/OpenClaw.Shared/Models.cs | 7 +++++++ src/OpenClaw.Shared/OpenClawGatewayClient.cs | 9 +++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs index 7223077af..483ccc1b7 100644 --- a/src/OpenClaw.Shared/Models.cs +++ b/src/OpenClaw.Shared/Models.cs @@ -92,6 +92,13 @@ public class OpenClawNotification public string? Agent { get; set; } // agent name/identifier public string? Intent { get; set; } // normalized intent (reminder, build, alert) public string[]? Tags { get; set; } // free-form routing tags + + /// + /// The session key associated with this notification (e.g. the chat session + /// that produced an assistant response). Used by toast activation to open + /// the specific session instead of the default/main session. + /// + public string? SessionKey { get; set; } } /// diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index 7120e7a02..e1f903de6 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -2560,7 +2560,7 @@ private void HandleChatEvent(JsonElement root, int rawMessageLength) // HIGH 4: log shape only — content previously // surfaced in the operator log. _logger.Info($"Assistant response: role={role} state={state} len={text.Length}"); - EmitChatNotification(text); + EmitChatNotification(text, sessionKey); } } } @@ -2580,7 +2580,7 @@ private void HandleChatEvent(JsonElement root, int rawMessageLength) { // HIGH 4: log shape only. _logger.Info($"Assistant response (legacy): role={role} state={state} len={text.Length}"); - EmitChatNotification(text); + EmitChatNotification(text, sessionKey); } } } @@ -2691,13 +2691,14 @@ private void EmitRawChatEvent(JsonElement payload) } } - private void EmitChatNotification(string text) + private void EmitChatNotification(string text, string? sessionKey = null) { var displayText = text.Length > 200 ? text[..200] + "…" : text; var notification = new OpenClawNotification { Message = displayText, - IsChat = true + IsChat = true, + SessionKey = sessionKey }; var (title, type) = _categorizer.Classify(notification, _userRules); notification.Title = title; From b4b6df0cd6d3b0c91fbc406e6f7b5c598a7da887 Mon Sep 17 00:00:00 2001 From: QQ Date: Sun, 31 May 2026 19:42:24 +0800 Subject: [PATCH 2/2] Fix chat session routing from Sessions page and toast notifications Previously, clicking a session in the Sessions tab or a chat toast notification always opened the default/main session instead of the specific session. Changes: - Add App.PendingChatSessionKey as a fallback when HubWindow does not exist (background notifications, closed HubWindow). - Thread sessionKey through ToastActivationRouter (Action). - Embed sessionKey in chat toast arguments and route it on activation. - ChatPage (functional): consume pending key from both HubWindow and App fallbacks; use !string.IsNullOrEmpty guard to treat empty string as null. - ChatPage (WebView): consume pending key and append &session={key} to the navigation URL, or pass it through GatewayChatHelper on initial WebView init. - SessionsPage: write key to CurrentApp.PendingChatSessionKey in addition to hub.PendingChatSessionKey. - Update tests for the new OpenChat Action contract. Fixes session routing for both native FunctionalUI and legacy WebView2 chat surfaces. --- .../App.ToastActivation.cs | 2 +- src/OpenClaw.Tray.WinUI/App.xaml.cs | 35 ++++++++++--- .../Pages/ChatPage.xaml.cs | 49 ++++++++++++++----- .../Pages/SessionsPage.xaml.cs | 3 ++ .../Services/ToastActivationRouter.cs | 5 +- .../AppRefactorContractTests.cs | 2 +- .../ToastActivationRouterTests.cs | 22 ++++++++- 7 files changed, 95 insertions(+), 23 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/App.ToastActivation.cs b/src/OpenClaw.Tray.WinUI/App.ToastActivation.cs index a4b6a65d2..777a95ead 100644 --- a/src/OpenClaw.Tray.WinUI/App.ToastActivation.cs +++ b/src/OpenClaw.Tray.WinUI/App.ToastActivation.cs @@ -24,7 +24,7 @@ private void OnToastActivated(ToastNotificationActivatedEventArgsCompat args) }, OpenDashboard = () => OpenDashboard(), OpenSettings = ShowSettings, - OpenChat = ShowWebChat, + OpenChat = sessionKey => ShowWebChat(sessionKey), OpenActivity = () => ShowHub("channels"), CopyPairingCommand = command => { diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 51b1d1970..f4544c2f9 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -61,6 +61,13 @@ public partial class App : Application, OpenClawTray.Services.IAppCommands /// The full device ID of the local node service (if running). internal string? NodeFullDeviceId => _nodeService?.FullDeviceId; + /// + /// Session key that the chat surface should select on its next mount. + /// Used when the user clicks a session from SessionsPage or a notification + /// while the HubWindow may not yet exist. Consumed (cleared) by ChatPage. + /// + public string? PendingChatSessionKey { get; set; } + public OpenClawTray.Chat.OpenClawChatDataProvider? ChatProvider => _chatCoordinator?.Provider; /// @@ -2411,10 +2418,15 @@ private void OnGatewayNotificationReceived(object? sender, OpenClawNotification if (notification.IsChat) { - builder.AddArgument("action", "open_chat") - .AddButton(new ToastButton() - .SetContent("Open Chat") - .AddArgument("action", "open_chat")); + builder.AddArgument("action", "open_chat"); + if (!string.IsNullOrEmpty(notification.SessionKey)) + { + builder.AddArgument("sessionKey", notification.SessionKey); + } + builder.AddButton(new ToastButton() + .SetContent("Open Chat") + .AddArgument("action", "open_chat") + .AddArgument("sessionKey", notification.SessionKey ?? "")); } _toastService!.ShowToast(builder); @@ -2805,7 +2817,7 @@ private void OnSettingsSaved(object? sender, EventArgs e) } } - private void ShowWebChat() + private void ShowWebChat(string? sessionKey = null) { if (_settings == null) return; if (!TryResolveChatCredentials(out _, out _, out _, out var isBootstrapToken)) @@ -2824,6 +2836,17 @@ private void ShowWebChat() return; } + // Stash the session key on both App (fallback when HubWindow doesn't exist) + // and HubWindow (existing path) so ChatPage can pick it up after navigation. + if (!string.IsNullOrEmpty(sessionKey)) + { + PendingChatSessionKey = sessionKey; + if (_hubWindow != null) + { + _hubWindow.PendingChatSessionKey = sessionKey; + } + } + ShowHub("chat"); } @@ -3896,7 +3919,7 @@ private void HandleDeepLink(string uri) CopyActivitySummary = _diagnosticsClipboard!.CopyActivitySummary, CopyExtensibilitySummary = _diagnosticsClipboard!.CopyExtensibilitySummary, RestartSshTunnel = RestartSshTunnel, - OpenChat = ShowWebChat, + OpenChat = () => ShowWebChat(), OpenCommandCenter = ShowStatusDetail, OpenTrayMenu = ShowTrayMenuPopup, OpenActivityStream = ShowActivityStream, diff --git a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs index 37aaa0f53..f64f7a504 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs @@ -35,6 +35,7 @@ public sealed partial class ChatPage : Page private global::Windows.Foundation.TypedEventHandler? _navCompletedHandler; private global::Windows.Foundation.TypedEventHandler? _navStartingHandler; private IGatewayConnectionManager? _connectionManager; + private string? _pendingWebViewSessionKey; private static readonly HttpClient s_httpClient = new() { Timeout = TimeSpan.FromSeconds(3) @@ -213,18 +214,20 @@ private void ShowFunctionalSurface() var provider = app?.ChatProvider; Func? readAloud = app is null ? null : app.SpeakChatTextAsync; - // Consume a pending session-key hand-off from SessionsPage so the - // chat root mounts with that thread selected. Any pending key forces - // a remount — _mountedThreadId only records what we asked for, not - // what the user later picked inside the composer's dropdown, so we - // cannot use it to detect "already on the right thread". - var pendingSessionKey = _hub?.PendingChatSessionKey; - if (pendingSessionKey is not null && _hub is not null) + // Consume a pending session-key hand-off from SessionsPage or a + // notification toast so the chat root mounts with that thread selected. + // Any pending key forces a remount — _mountedThreadId only records what + // we asked for, not what the user later picked inside the composer's + // dropdown, so we cannot use it to detect "already on the right thread". + var pendingSessionKey = _hub?.PendingChatSessionKey + ?? (App.Current as App)?.PendingChatSessionKey; + if (!string.IsNullOrEmpty(pendingSessionKey)) { - _hub.PendingChatSessionKey = null; + if (_hub is not null) _hub.PendingChatSessionKey = null; + if (App.Current is App currentApp) currentApp.PendingChatSessionKey = null; } var threadIdToMount = pendingSessionKey ?? _mountedThreadId; - var forceRemount = pendingSessionKey is not null; + var forceRemount = !string.IsNullOrEmpty(pendingSessionKey); if (_functionalHost is not null && ReferenceEquals(_mountedProvider, provider) @@ -288,6 +291,20 @@ private void ShowFunctionalSurface() private void ShowWebViewSurface(bool forceNavigate = false) { + // Consume pending session key for WebView mode. + var pendingSessionKey = _hub?.PendingChatSessionKey + ?? (App.Current as App)?.PendingChatSessionKey; + if (!string.IsNullOrEmpty(pendingSessionKey)) + { + if (_hub is not null) _hub.PendingChatSessionKey = null; + if (App.Current is App currentApp) currentApp.PendingChatSessionKey = null; + _pendingWebViewSessionKey = pendingSessionKey; + } + else + { + _pendingWebViewSessionKey = null; + } + // Tear down native chat (so the WebView2 owns the row) and (re)init WebView2. _webViewMode = true; DisposeFunctionalHost(); @@ -325,9 +342,18 @@ private bool NavigateWebViewToCurrentChatUrl() if (string.IsNullOrEmpty(_chatUrl) || WebView.CoreWebView2 is null) return false; + var url = _chatUrl; + if (!string.IsNullOrEmpty(_pendingWebViewSessionKey)) + { + var baseUrl = System.Text.RegularExpressions.Regex.Replace(_chatUrl, @"[&?]session=[^&]*", ""); + var separator = baseUrl.Contains('?') ? "&" : "?"; + url = $"{baseUrl}{separator}session={Uri.EscapeDataString(_pendingWebViewSessionKey)}"; + _pendingWebViewSessionKey = null; + } + ErrorPanel.Visibility = Visibility.Collapsed; WebView.Visibility = Visibility.Visible; - WebView.CoreWebView2.Navigate(_chatUrl); + WebView.CoreWebView2.Navigate(url); return true; } @@ -392,7 +418,7 @@ private async Task InitializeWebViewAsync(SettingsManager settings) return; } - if (!GatewayChatHelper.TryBuildChatUrl(credential.GatewayUrl, credential.Token, out var chatUrl, out var errorMessage)) + if (!GatewayChatHelper.TryBuildChatUrl(credential.GatewayUrl, credential.Token, out var chatUrl, out var errorMessage, _pendingWebViewSessionKey)) { PlaceholderPanel.Visibility = Visibility.Collapsed; ErrorPanel.Visibility = Visibility.Visible; @@ -401,6 +427,7 @@ private async Task InitializeWebViewAsync(SettingsManager settings) } _chatUrl = chatUrl; + _pendingWebViewSessionKey = null; PlaceholderPanel.Visibility = Visibility.Collapsed; ErrorPanel.Visibility = Visibility.Collapsed; diff --git a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml.cs index f47d80935..48d3f2759 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml.cs @@ -242,6 +242,9 @@ private void OnOpenChat(object sender, RoutedEventArgs e) { if (sender is Button btn && btn.Tag is string key) { + // Stash the target session on both App (fallback when the HubWindow + // doesn't exist yet) and HubWindow (existing path consumed by ChatPage). + CurrentApp.PendingChatSessionKey = key; if (CurrentApp.ActiveHubWindow is HubWindow hub) { hub.PendingChatSessionKey = key; diff --git a/src/OpenClaw.Tray.WinUI/Services/ToastActivationRouter.cs b/src/OpenClaw.Tray.WinUI/Services/ToastActivationRouter.cs index da957f2d2..a2f5d5619 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ToastActivationRouter.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ToastActivationRouter.cs @@ -5,7 +5,7 @@ public sealed class ToastActivationActions public required Action OpenUrl { get; init; } public required Action OpenDashboard { get; init; } public required Action OpenSettings { get; init; } - public required Action OpenChat { get; init; } + public required Action OpenChat { get; init; } public required Action OpenActivity { get; init; } public required Action CopyPairingCommand { get; init; } } @@ -34,7 +34,8 @@ public static void Route( actions.OpenSettings(); break; case "open_chat": - actions.OpenChat(); + var sessionKey = getArgument("sessionKey"); + actions.OpenChat(sessionKey); break; case "open_activity": actions.OpenActivity(); diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 6f430cd5a..89aaa3a9e 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -82,7 +82,7 @@ public void ToastActivation_RoutesOnUiThread() Assert.Contains("ToastActivationRouter.Route", method); Assert.Contains("OpenDashboard = () => OpenDashboard()", method); Assert.Contains("OpenSettings = ShowSettings", method); - Assert.Contains("OpenChat = ShowWebChat", method); + Assert.Contains("OpenChat = sessionKey => ShowWebChat(sessionKey)", method); Assert.Contains("OpenActivity = () => ShowHub(\"channels\")", method); Assert.Contains("CopyPairingCommand = command =>", method); } diff --git a/tests/OpenClaw.Tray.Tests/ToastActivationRouterTests.cs b/tests/OpenClaw.Tray.Tests/ToastActivationRouterTests.cs index a1d384779..11d96799b 100644 --- a/tests/OpenClaw.Tray.Tests/ToastActivationRouterTests.cs +++ b/tests/OpenClaw.Tray.Tests/ToastActivationRouterTests.cs @@ -7,7 +7,7 @@ public sealed class ToastActivationRouterTests [Theory] [InlineData("open_dashboard", "dashboard")] [InlineData("open_settings", "settings")] - [InlineData("open_chat", "chat")] + [InlineData("open_chat", "chat:(null)")] [InlineData("open_activity", "activity")] public void Route_DispatchesSimpleActions(string action, string expected) { @@ -57,6 +57,24 @@ public void Route_CopyPairingCommand_RequiresCommandArgument() Assert.Equal(["copy:openclaw pair approve abc"], calls); } + [Fact] + public void Route_OpenChat_PassesSessionKeyArgument() + { + var calls = new List(); + + ToastActivationRouter.Route( + "open_chat", + key => key == "sessionKey" ? "agent:main:scratch" : null, + BuildActions(calls)); + + ToastActivationRouter.Route( + "open_chat", + _ => null, + BuildActions(calls)); + + Assert.Equal(["chat:agent:main:scratch", "chat:(null)"], calls); + } + [Fact] public void Route_UnknownAction_NoOps() { @@ -75,7 +93,7 @@ public void Route_UnknownAction_NoOps() OpenUrl = url => calls.Add($"url:{url}"), OpenDashboard = () => calls.Add("dashboard"), OpenSettings = () => calls.Add("settings"), - OpenChat = () => calls.Add("chat"), + OpenChat = key => calls.Add($"chat:{key ?? "(null)"}"), OpenActivity = () => calls.Add("activity"), CopyPairingCommand = command => calls.Add($"copy:{command}") };