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}")
};