diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs index 7223077a..483ccc1b 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 7120e7a0..e1f903de 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; diff --git a/src/OpenClaw.Tray.WinUI/App.ToastActivation.cs b/src/OpenClaw.Tray.WinUI/App.ToastActivation.cs index a4b6a65d..777a95ea 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 51b1d197..f4544c2f 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 37aaa0f5..f64f7a50 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 f47d8093..48d3f275 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 da957f2d..a2f5d561 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 6f430cd5..89aaa3a9 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 a1d38477..11d96799 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}") };